mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Compare commits
128
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e99b42c387 | ||
|
|
574eae72f3 | ||
|
|
75ea908f29 | ||
|
|
d44b049c51 | ||
|
|
259cabd738 | ||
|
|
f1b2a2ab75 | ||
|
|
03cb6a68b4 | ||
|
|
6fb8b27768 | ||
|
|
f604deba3e | ||
|
|
60f1900081 | ||
|
|
fa4815b465 | ||
|
|
a26be7c98f | ||
|
|
88ed3eef37 | ||
|
|
4423949923 | ||
|
|
5396e33b48 | ||
|
|
724078926e | ||
|
|
a13f5e7b13 | ||
|
|
1b7e03cd2c | ||
|
|
b4e44c4f6a | ||
|
|
b5c393966a | ||
|
|
7db66075a8 | ||
|
|
d4928baa36 | ||
|
|
5ae3224256 | ||
|
|
faca681c90 | ||
|
|
d873555cc2 | ||
|
|
992e43bc5c | ||
|
|
6fd4af40fb | ||
|
|
781e39022a | ||
|
|
ee0c9f4db9 | ||
|
|
b724f2d6ea | ||
|
|
9c2ba35f38 | ||
|
|
db81d55fc9 | ||
|
|
cb4be83130 | ||
|
|
d192eb6825 | ||
|
|
b587ac71f4 | ||
|
|
6c2e257d48 | ||
|
|
81c7708353 | ||
|
|
482ffe2525 | ||
|
|
c2d31b11f4 | ||
|
|
d45b1ff248 | ||
|
|
59d4bbca2d | ||
|
|
18b309fc02 | ||
|
|
68e010b887 | ||
|
|
6fa2dfa549 | ||
|
|
0962677ed5 | ||
|
|
062aa980d2 | ||
|
|
560377b228 | ||
|
|
f7fccc5dfc | ||
|
|
f07cbccb0b | ||
|
|
c9f84c443d | ||
|
|
2da3cf3853 | ||
|
|
be8b96fa5d | ||
|
|
3eb0689f6d | ||
|
|
afc5d7ab2a | ||
|
|
d04fb63837 | ||
|
|
bd362f736b | ||
|
|
13f1b986af | ||
|
|
fbcf8eba72 | ||
|
|
1d7288dc4f | ||
|
|
ae6770f03b | ||
|
|
ea1697ac58 | ||
|
|
cda1572b77 | ||
|
|
0f9f877a5a | ||
|
|
aa8afdf2f6 | ||
|
|
452f7c41dd | ||
|
|
bdd882bce8 | ||
|
|
9a34797cd5 | ||
|
|
0442b4cdbf | ||
|
|
d22bfaaaaf | ||
|
|
a5a1b6f9a2 | ||
|
|
8bedbbe793 | ||
|
|
7bdef0d86a | ||
|
|
2f17b8067a | ||
|
|
70dc42b248 | ||
|
|
d25cc08606 | ||
|
|
9608a17d99 | ||
|
|
84229de575 | ||
|
|
4f2b7dfec9 | ||
|
|
eef02ab00a | ||
|
|
43e13e1b5d | ||
|
|
a032e9829a | ||
|
|
5cd8fa049a | ||
|
|
979275af1f | ||
|
|
ede99b309c | ||
|
|
c307d63d3b | ||
|
|
4c53d424a7 | ||
|
|
39f2e847d8 | ||
|
|
7a48778bf4 | ||
|
|
91a4aa6b4e | ||
|
|
3a3e2ee1a4 | ||
|
|
27d6e2e30a | ||
|
|
6c5d53ebfe | ||
|
|
0bbf483a35 | ||
|
|
8b15c96c0f | ||
|
|
e06257c937 | ||
|
|
0e13d9b12f | ||
|
|
7f35f798ee | ||
|
|
308de1fd4d | ||
|
|
2d7cd46565 | ||
|
|
0cedeb2657 | ||
|
|
4f29b0187a | ||
|
|
687c75b490 | ||
|
|
9518f4ecec | ||
|
|
8d7d0c5c7f | ||
|
|
430bf6217b | ||
|
|
0bc5f0da14 | ||
|
|
8dc81d0089 | ||
|
|
5e80c0a1ca | ||
|
|
20cd2eccc9 | ||
|
|
2f9843c94a | ||
|
|
607c88afd9 | ||
|
|
bb088bcd3e | ||
|
|
79a6c03363 | ||
|
|
4808ee7085 | ||
|
|
ea27adf104 | ||
|
|
7d96b3dcf6 | ||
|
|
078bd74bd1 | ||
|
|
dfe1af2e5f | ||
|
|
572ff202bb | ||
|
|
ba6bff388a | ||
|
|
f8ff431d30 | ||
|
|
2a876731f6 | ||
|
|
f62c1f7e2f | ||
|
|
7602b6db21 | ||
|
|
86502b6904 | ||
|
|
a50d0db283 | ||
|
|
d62b11586b | ||
|
|
84a6896cae |
No files matched your search
@@ -10,7 +10,7 @@
|
|||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"source": "./plugins/codeman",
|
"source": "./plugins/codeman",
|
||||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
"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.50.0",
|
"version": "1.51.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -1,5 +1,75 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.51.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 482ffe2: ### Thanks
|
||||||
|
- @Randalix for auto-compact and auto-clear that see the whole context (#777), and for the measurements in #585 that showed why they did not.
|
||||||
|
- @JDProfresh for the report and the change that lets the File Viewer edit `.env` files (#796, #797).
|
||||||
|
- @aakhter for the Copy Markdown button in the response viewer (#810).
|
||||||
|
- @DodgyBadger for the File Viewer button that follows its setting on a phone (#759, #809).
|
||||||
|
- @timkjr for omp tool approvals that raise an alert (#798).
|
||||||
|
- @irisitymichaelgrundberg for finding why Firefox still pasted twice (#799).
|
||||||
|
|
||||||
|
**Local models: a library, more models, and the coding agent you choose (#803, #804, #806, #807, #808).** Run a local model (on a Mac) now lists every model that fits instead of five, with two new ones: Muse Glimmer 30B, the first of its class a 32 GB Mac can hold, and Ornith 1.0 9B, the first model that fits a 16 GB Mac. Installed models form a library: each shows its size on disk, and you can add another, switch between them or delete one. You pick the coding agent when you start a session (OpenCode, Claude Code, Pi or DeepSeek Harness), and a missing one installs from the same screen. The codeman skill starts a local worker with the agent you name (`spawn_worker <name> local:pi`), and the wiki has a new Local Models page. Still experimental.
|
||||||
|
|
||||||
|
**Mac app: With the Lid Closed no longer asks for a password.** Codeman > Keep This Mac Awake > With the Lid Closed is a plain switch now. 1.50.1 asked for your administrator password and installed a small root helper for it. The app now does what [LogoLight](https://logolight.app) does: it turns off macOS's sleep-on-lid-close itself while your agents work, and turns it back on when they stop, when the app quits or crashes, and on battery. If you ticked With the Lid Closed in 1.50.1, it keeps working after the update, and the old helper does nothing any more. Remove it with Codeman > Keep This Mac Awake > Remove the Old Lid Helper… (that asks for your password one last time). If the Mac ever sleeps under the lid anyway, the status line in the same menu says until when.
|
||||||
|
|
||||||
|
**Auto-compact and auto-clear see the whole context (#777).** Both compared a token counter that never counted the cached part of a conversation, so on a long session they fired far too late or not at all. For Claude sessions they now read the context size Claude Code reports, and a threshold can go up to the model's window (1M with extended context). Behaviour change: a session with auto-compact or auto-clear on fires earlier than it did, because 110k and 140k now mean the real context. Other CLIs, remote and Docker sessions keep the old counter.
|
||||||
|
|
||||||
|
**jcode as a run mode (#650).** Codeman can now start [jcode](https://github.com/1jehuang/jcode) sessions from the Run menu, the phone overview and the API (`mode: "jcode"`), locally, over SSH and in Docker cases. jcode runs every session's tools in one shared background daemon, so closing a jcode tab never stops that daemon, its sessions get no Codeman agent skill, and a jcode worker's answer is read from its terminal; see `docs/jcode-integration.md`.
|
||||||
|
|
||||||
|
**The File Viewer edits `.env` files (#796, #797).** A workspace `.env`, `.env.local` or `prod.env` opened in the preview but could never be edited. It now gets the pencil like any config file, and a save keeps the file's mode and group. Other credential files (`.npmrc`, `credentials.json`, anything under `.ssh/`) and Codeman's own `~/.codeman/.env` stay read-only there, and downloads and attachments still refuse dotenv files.
|
||||||
|
|
||||||
|
**Response viewer: a Copy Markdown button (#810).** Last Response has a Copy Markdown button. It copies the raw source of the newest answer, every part of its turn, so headings, lists and code fences survive a paste. Tool and status blocks are left out, and the full view (More) copies the same answer.
|
||||||
|
|
||||||
|
**Fixes.**
|
||||||
|
- **Firefox no longer pastes twice on Ctrl+V (#799).** Firefox sent a second paste for the same key press, and some of the time it reached the terminal.
|
||||||
|
- **An omp pane that asks for a tool approval alerts (#798).** It raised no tab alert, no notification and no Approvals Inbox item. Behaviour change: Codeman writes a small hook file into `.claude/hooks/pre/` of an omp session's folder.
|
||||||
|
- **Phone: the File Viewer header button follows its setting (#759, #809).** Turning on File Viewer under App Settings > Header & Panels did nothing on a phone. The button now shows there once you turn it on, with a full-size touch target, and stays off until you do. Behaviour change: a desktop window or an iPad split view narrower than 600px shows the button too, where it was hidden before.
|
||||||
|
- **Local model: the "still using this model" refusal names every session (#804).** With a session open that an agent had started, Stop and Remove answered "Sessions are still using this model: . Close them first." Such a session has no name of its own, so the sentence now uses the name its tab shows.
|
||||||
|
- **Skill: two workers in one folder keep their own answers (#807).** On a Mac, where Claude Code runs zsh, two `sendmark` calls started in the same second named the same answer file, and one worker's written answer replaced the other's. Workers in folders of their own were not affected.
|
||||||
|
|
||||||
|
**Fixes applied while landing.** Auto-compact and auto-clear decide again at the moment the command goes out, so a `/clear` is not sent into a conversation Claude Code just compacted itself, into a session paused on a usage limit, or after you switched the option off (#777). The File Viewer cannot reach Codeman's own `.env` by another spelling on a case-insensitive disk (#797). An omp alert stays up until omp says its dialog closed (#798). The test gate checks again that the File Viewer button is off on a phone nobody turned it on for (#809). The Copy Markdown button is named by the words on it, so voice control can find it (#810).
|
||||||
|
|
||||||
|
**Docs and tests.** The experimental API list names the new local model routes, the wiki's Local Models page walks from install to cloud plus local, and the wiki says where the File Viewer button is on a phone and what Copy Markdown copies.
|
||||||
|
|
||||||
|
## 1.50.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- e06257c: ### Thanks
|
||||||
|
- @opticon454 for syncing case options and the picked case across devices, over five review rounds (#732).
|
||||||
|
- @ludovicfourrage for dictation that survives a thinking pause and the Android dictation fixes, over three review rounds (#705).
|
||||||
|
- @johoja12 for the log viewer reconnect fix (#720).
|
||||||
|
- @shenlvkang-collab for the image zoom (#608) and the Files panel delete button (#529), both carried forward in #791 and #792, and for the foldable phone report (#702).
|
||||||
|
|
||||||
|
**A MacBook keeps working with the lid closed (#795).** The Codeman app for Mac has a new menu, Codeman > Keep This Mac Awake: Off, While Agents Work (and 5 minutes after the last one stops) or Always. It only acts for a Codeman running on this Mac, and pauses on battery unless you untick Only When Plugged In. On a MacBook, With the Lid Closed… keeps a closed Mac awake on its power adapter. It asks for your administrator password once and installs a small helper; unticking it removes the helper. Behaviour change: while it keeps watch for a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, so the Mac stays awake; click the Dock icon to bring it back, and quit with Cmd+Q (#800). With no server open, or one on another machine, closing the window still quits.
|
||||||
|
|
||||||
|
**The lid logo breathes while agents work (#794).** The Apple logo blink from 1.50.0 now has one switch, View > Blink Apple Logo. It breathes while agents work, blinks for a yellow tab and flashes for a red one: the more it moves, the more it needs you. It now moves on the logo's own hardware fades, so a blink no longer writes a system setting. Styles you picked in 1.50.0 are kept.
|
||||||
|
|
||||||
|
**The logo keeps blinking with the lid closed (#801).** While the Mac stays awake behind a closed lid on its power adapter (Keep This Mac Awake > With the Lid Closed…, or an external display), the logo goes on showing your agents, and the keyboard stays dark. macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open, the way LogoLight 1.3.1 does. A line under View > Apple Logo Blink Styles says what the closed lid did last: blinking, or why not. On battery nothing changes: the Mac sleeps when you close it.
|
||||||
|
|
||||||
|
**Codeman and LogoLight take turns on the logo (#802).** With LogoLight 1.3 or later running, LogoLight steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards. Until now Codeman left the logo alone whenever LogoLight ran. An older LogoLight still keeps the logo, and Codeman tells you to update it when you turn the switch on.
|
||||||
|
|
||||||
|
**Dictation survives a thinking pause (#705).** A new Voice setting, Stop after silence, sets how long a pause may last before dictation stops: the provider's default, 3, 6, 15 or 30 seconds, or Manual. Claude dictation keeps going from one sentence to the next until you press Stop, and keeps your last words. On Chrome for Android, words are no longer repeated or lost at the end. Behaviour change: on iOS Safari dictation no longer stops after 750 ms without new words; it stops at the silence deadline like everywhere else.
|
||||||
|
|
||||||
|
**Sync cases across devices (#732).** A new setting, off by default, keeps each case's Agent Teams and Opus 1M options on the server instead of in each browser. When you come back to a tab, the case picker follows the case you last picked on another device.
|
||||||
|
|
||||||
|
**Delete a file from the Files panel (#792).** File rows in the Files panel, tree and search results, have a delete button that asks first. Symbolic links, `.git`, sensitive files and remote or adopted sessions are refused.
|
||||||
|
|
||||||
|
**Zoom the path picker's image preview (#791).** Pinch and drag on a phone, the mouse wheel or a trackpad on a desktop, double-click for 2x, or the zoom buttons. A small image now shows at its own size instead of stretched.
|
||||||
|
|
||||||
|
**Fixes.**
|
||||||
|
- **The phone overview appears on a fold (#790).** Folding a phone, or narrowing a window below 600px, left the welcome screen up when the page had loaded wide. Refs #702.
|
||||||
|
- **The log viewer stops reconnecting after a startup error (#720).** A refused path, a missing or too large file, the stream limit or a failed tail made the floating log viewer reconnect every few seconds.
|
||||||
|
- **The local model install view no longer flashes Paused at the end.** A view read while the install job finished could show the job as paused.
|
||||||
|
|
||||||
|
**Fixes applied while landing.** #720 keeps the browser's own retry after a network error before the stream connects. The Mac app's window-close change (#800) came from checking #795 against how people quit the app. #705: the green Send button now stays up for the words saved when dictation is cut off by a reconnect, and the API reference says what a voice transcript frame holds.
|
||||||
|
|
||||||
|
**Docs and tests.** Both READMEs have a Download for Mac button at the top. The Mac app's code was reorganised into smaller modules (#789) with no change in behaviour.
|
||||||
|
|
||||||
## 1.50.0
|
## 1.50.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ 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.
|
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.50.0 (must match `package.json`)
|
**Version**: 1.51.0 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
|
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
|
||||||
|
|
||||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`), OMP (`omp`) and GitHub Copilot (`copilot`, npm `@github/copilot`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'`).
|
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`), OMP (`omp`), GitHub Copilot (`copilot`, npm `@github/copilot`) and jcode (`jcode`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' | 'jcode'`).
|
||||||
|
|
||||||
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
|
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
|
||||||
|
|
||||||
@@ -76,6 +76,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| Regenerate the CLI catalogue | `npm run generate:cli-catalog` (`--check` to fail on drift). Rewrites `config/clis.stock.json` **and** the marked block in `install.sh` from `stock.ts`. ⚠ **Commit both.** They are what `install.sh` and the Docker agent image read, since neither can import TypeScript; `test/cli-catalog-sync.test.ts` and a CI `--check` step fail if either goes stale. See `docs/cli-registry.md` |
|
| Regenerate the CLI catalogue | `npm run generate:cli-catalog` (`--check` to fail on drift). Rewrites `config/clis.stock.json` **and** the marked block in `install.sh` from `stock.ts`. ⚠ **Commit both.** They are what `install.sh` and the Docker agent image read, since neither can import TypeScript; `test/cli-catalog-sync.test.ts` and a CI `--check` step fail if either goes stale. See `docs/cli-registry.md` |
|
||||||
| Build the docker agent image | `node scripts/build-agent-image.mjs --no-cache` (builds `codeman/agent:base` from `docker/agent.Dockerfile`; prerequisite for Docker cases; `--engine`/`--image`). ⚠ **Always `--no-cache`** — a plain rebuild re-uses the cached `npm install -g` layer and silently keeps the CLIs frozen at their original versions, which once shipped a BROKEN codex while reporting success. See `docs/docker-cases.md` |
|
| Build the docker agent image | `node scripts/build-agent-image.mjs --no-cache` (builds `codeman/agent:base` from `docker/agent.Dockerfile`; prerequisite for Docker cases; `--engine`/`--image`). ⚠ **Always `--no-cache`** — a plain rebuild re-uses the cached `npm install -g` layer and silently keeps the CLIs frozen at their original versions, which once shipped a BROKEN codex while reporting success. See `docs/docker-cases.md` |
|
||||||
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
|
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
|
||||||
|
| Dead-code report | `npm run knip` (`config/knip.json`; fetched with `npx`, so it needs the network and is not a CI step). It reports nothing on a clean tree: an export, file or dependency it lists is new dead code, or a manual script that needs an `entry`. Tests count as users, and `src/web/public/` is outside it (plain scripts with no imports). A deliberate keep carries `@knipignore` in its JSDoc |
|
||||||
|
| Frontend dead-code check | `npm run check:frontend-dead` (`scripts/check-frontend-dead-code.mjs`, a few seconds, not a CI step). `src/web/public/` is plain scripts that knip cannot follow, so this is its counterpart there: it fails on a function, method or top-level binding that nothing references (identifiers, strings, `index.html`, the gesture overlay's source, tests) and on a CSS class or id that no markup or script can produce. `--production` also lists what only tests reference. A deliberate keep goes in the script's `ALLOW` map with its reason |
|
||||||
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
|
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
|
||||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||||
| Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation |
|
| Detached server | `codeman web -d` (`--status`, `--stop`; pidfile+log at `dataPath('web.pid'/'web.log')`). ⚠ Refuses to start a 2nd server on one data dir — see Instance isolation |
|
||||||
@@ -92,7 +94,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `COPILOT_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `COPILOT_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so subagent windows, the ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). The response viewer and the rename's `/resume` title read a LOCAL session's own folder, then the server's `CLAUDE_CONFIG_DIR`, then `~/.claude` (`claudeConversationDirs()` in session-routes.ts over `claudeProjectsDir()`, as the Edit history panel and the relaunch pin do, #701): a relocated folder is probed bounded first, and a missing one reads as no transcript, never as a reason to look under `~/.claude`. ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
||||||
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
||||||
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
|
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
|
||||||
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
|
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
|
||||||
@@ -128,7 +130,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
|
||||||
|
|
||||||
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (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 ON since 1.50.0**, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi or copilot): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). ⚠️ An ABSENT key means ON on both sides (server `!== false`, the page's switch `!== false`, or a Save writes the old default back); only an explicit false is off, and turning it off still sweeps nothing (`codeman skill uninstall --case <name>` removes copies). ⚠️ Existing installs that stored off are switched on once at the first 1.50.0 start (every Save up to 1.49.0 wrote `false`); turning it off afterwards sticks. That is `migrateAgentSkillDefault()` (`web/agent-skill-default-migration.ts`, from `WebServer.start()` before the first settings read and before listen, skipped in test mode, through `mutateSettingsFile()` only): it stores `true`, never deletes the key (the page's merge keeps a device's own `false` for a key the server lacks, and the next Save would write it back; the page also drops its copy when the server has no key), and its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir, never a settings key (so no Save sends or drops it, the `.strict()` schema never sees it, and a config-backup restore leaves it alone). ⚠️ Every managed copy ignores itself in git: a `.gitignore` of `*` written FIRST with `wx` (never overwritten, never part of `readAgentSkillSource()`, so never in `skills/codeman/` or the plugin mirror), removed by uninstall only when it is the last file left; never touch the user's own ignore files for it. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
|
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen) or `capabilities.approvalHookFile` (omp, whose approval events a hook module in a LOCAL workspace forwards; `approvalHookAvailableForMode()` says no for an adopted, remote or Docker session); `blockedSignalAvailable()` is the rule: 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 ON since 1.50.0**, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi or copilot): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). ⚠️ An ABSENT key means ON on both sides (server `!== false`, the page's switch `!== false`, or a Save writes the old default back); only an explicit false is off, and turning it off still sweeps nothing (`codeman skill uninstall --case <name>` removes copies). ⚠️ Existing installs that stored off are switched on once at the first 1.50.0 start (every Save up to 1.49.0 wrote `false`); turning it off afterwards sticks. That is `migrateAgentSkillDefault()` (`web/agent-skill-default-migration.ts`, from `WebServer.start()` before the first settings read and before listen, skipped in test mode, through `mutateSettingsFile()` only): it stores `true`, never deletes the key (the page's merge keeps a device's own `false` for a key the server lacks, and the next Save would write it back; the page also drops its copy when the server has no key), and its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir, never a settings key (so no Save sends or drops it, the `.strict()` schema never sees it, and a config-backup restore leaves it alone). ⚠️ Every managed copy ignores itself in git: a `.gitignore` of `*` written FIRST with `wx` (never overwritten, never part of `readAgentSkillSource()`, so never in `skills/codeman/` or the plugin mirror), removed by uninstall only when it is the last file left; never touch the user's own ignore files for it. → [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 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`
|
**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`
|
||||||
|
|
||||||
@@ -160,6 +162,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
|
||||||
|
|
||||||
|
**Auto-compact and auto-clear** (opt-in per session, Respawn tab; `session-auto-ops.ts`, pure decision `decideAutoThreshold()`): for a Claude session the size compared with the threshold is the context Claude Code reports on its statusline (`context_window.total_input_tokens`, cache reads included, handed over by `POST /api/status-telemetry` through `Session.noteContextReport()`); the session's token counter never sees the cached context and is only the fallback for a launch without the exporter. Thresholds go up to the reported window (`contextWindowSize`, 1M while unknown). ⚠️ After a `/compact` or `/clear` the report from before the command is ignored by VALUE, never for a length of time (a 601k compact took 39 s). ⚠️ The decision is made AGAIN when the command goes out (`_decideAtSend()`): a report fires the trigger in the middle of a turn, and the wait for that turn to end can outlast a compaction by Claude Code itself, so a `/clear` sent on the old answer would wipe a conversation that is small again. A skipped command stamps nothing. ⚠️ Nothing is sent into a usage-limit pause (`_heldByLimit()`, until auto-resume's `continue` went out), or that `continue` is typed into an empty conversation. → [architecture-invariants#auto-compact-and-auto-clear-thresholds](docs/architecture-invariants.md#auto-compact-and-auto-clear-thresholds)
|
||||||
|
|
||||||
**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, SYNCED; `docs/wiki/Config-Backups.md`): timestamped `cfg-*` snapshots of a FIXED allowlist of config files (`CONFIG_BACKUP_FILES`; `state.json` deliberately not) at startup and whenever their content changes, into `<data dir>/backups/config` or `configBackupDir`. ⚠️ A field a tracked file rewrites on its own goes in `VOLATILE_KEYS`, or every tick makes a snapshot and the history shrinks to about 95 minutes. ⚠️ Retention deletes only this instance's folders that carry a valid manifest, never the newest, and ages a snapshot from its SUCCESSOR; a retention key ABSENT from `settings.json` means what the newest manifest recorded (`withRecordedRetention`), never the defaults. ⚠️ The instance is identified by the random id in `config-backup-id`, never the hostname (a Docker recreate changes it). ⚠️ A custom folder is probed BOUNDED (#516) before ANY read, the recorded-retention fallback included, and a pass reads the folder once (`runConfigBackupPass`). ⚠️ Restore verifies every SHA-256 and refuses symlinks and group/world bits before it writes anything, takes the undo copy first, and reports a partial failure as 500 with `restored`/`failed`/`safetySnapshot` in `data`; every path that wrote files runs `afterRestore()` (intents reload, CLI registry reload, admin audit), and Settings re-reads the form after a `settings.json` restore. ⚠️ Compare never returns a credential (`REDACT_ALL_FILES`, `SECRET_KEY_RE`). Admin only in multi-user mode. → [architecture-invariants#config-backups](docs/architecture-invariants.md#config-backups)
|
**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, SYNCED; `docs/wiki/Config-Backups.md`): timestamped `cfg-*` snapshots of a FIXED allowlist of config files (`CONFIG_BACKUP_FILES`; `state.json` deliberately not) at startup and whenever their content changes, into `<data dir>/backups/config` or `configBackupDir`. ⚠️ A field a tracked file rewrites on its own goes in `VOLATILE_KEYS`, or every tick makes a snapshot and the history shrinks to about 95 minutes. ⚠️ Retention deletes only this instance's folders that carry a valid manifest, never the newest, and ages a snapshot from its SUCCESSOR; a retention key ABSENT from `settings.json` means what the newest manifest recorded (`withRecordedRetention`), never the defaults. ⚠️ The instance is identified by the random id in `config-backup-id`, never the hostname (a Docker recreate changes it). ⚠️ A custom folder is probed BOUNDED (#516) before ANY read, the recorded-retention fallback included, and a pass reads the folder once (`runConfigBackupPass`). ⚠️ Restore verifies every SHA-256 and refuses symlinks and group/world bits before it writes anything, takes the undo copy first, and reports a partial failure as 500 with `restored`/`failed`/`safetySnapshot` in `data`; every path that wrote files runs `afterRestore()` (intents reload, CLI registry reload, admin audit), and Settings re-reads the form after a `settings.json` restore. ⚠️ Compare never returns a credential (`REDACT_ALL_FILES`, `SECRET_KEY_RE`). Admin only in multi-user mode. → [architecture-invariants#config-backups](docs/architecture-invariants.md#config-backups)
|
||||||
|
|
||||||
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). Copilot comes from a read-only `GET https://api.github.com/copilot_internal/user` (`refreshCopilotPlanUsage()` in server.ts, at startup and every 10 minutes; `parseCopilotQuotaResponse()` keeps only the metered `premium_interactions` quota, as a `monthly` window with `used`/`limit`) sent with Copilot's OWN sign-in token (`resolveCopilotToken()`: `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`, else the plain-text token in `<COPILOT_HOME or ~/.copilot>/config.json`; a keychain sign-in shows no row). It is **on by default**, like the Codex poll: it rides `showPlanUsageLimits` (absent = ON), and Plan Usage Limits off stops the request. ⚠️ The gates live in `readCopilotPlanUsageIfEnabled()` (copilot-plan-usage.ts) in this order: the setting, then a token, then `isCopilotAvailable()`, whose resolver can run a login-shell probe that a host with no sign-in must never pay each tick. ⚠️ `redirect: 'error'`, the token is never logged, stored or broadcast, any failure is silently no row, and the row is admin-only on the wire like Codex's. The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). Copilot comes from a read-only `GET https://api.github.com/copilot_internal/user` (`refreshCopilotPlanUsage()` in server.ts, at startup and every 10 minutes; `parseCopilotQuotaResponse()` keeps only the metered `premium_interactions` quota, as a `monthly` window with `used`/`limit`) sent with Copilot's OWN sign-in token (`resolveCopilotToken()`: `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`, else the plain-text token in `<COPILOT_HOME or ~/.copilot>/config.json`; a keychain sign-in shows no row). It is **on by default**, like the Codex poll: it rides `showPlanUsageLimits` (absent = ON), and Plan Usage Limits off stops the request. ⚠️ The gates live in `readCopilotPlanUsageIfEnabled()` (copilot-plan-usage.ts) in this order: the setting, then a token, then `isCopilotAvailable()`, whose resolver can run a login-shell probe that a host with no sign-in must never pay each tick. ⚠️ `redirect: 'error'`, the token is never logged, stored or broadcast, any failure is silently no row, and the row is admin-only on the wire like Codex's. The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
|
||||||
@@ -182,19 +186,19 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**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`
|
**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)
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot, jcode)**: `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 ten **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). ⚠️ **jcode**: every pane is a client of ONE shared per-user daemon and the tools run in the daemon with ITS env, so jcode gets no agent skill and no `JCODE_*` envOverrides prefix (a per-session value would act as another session or reach every one); `last-response` is an honest empty (external + `transcript: 'none'`, never the segmenter or the Claude scan); ⚠️ that daemon is a descendant of whichever pane started it, so `capabilities.sharedDaemon` makes `killSession` spare descendants outside the pane's process group (`descendantsInPaneGroup()`, `proc-tree.ts`), or closing that one tab cuts every other jcode session off; it resolves through the generic registry resolver, with no per-CLI resolver module (`docs/jcode-integration.md`). → [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)
|
||||||
|
|
||||||
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
|
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
|
||||||
|
|
||||||
**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`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
|
**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`, `lastPickedCase`, `caseSettings`) 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`, `lastPickedCase`, `caseSettings`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, so a failed or refused registry write puts `settings.json` back and leaves the registry as it was. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`; the one direct writer is config-backup restore, `restoreConfigBackup()`, which must work on such a file), and a check that must still hold when the entry is written (the name is free; for a rename, the old entry still names the folder) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`, and for a rename by `test/routes/case-rename-routes.test.ts`.
|
||||||
|
|
||||||
**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); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ 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()`. ⚠️ Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()` (`src/custom-model-hosts-writer.ts`: one at a time, temp file + rename, the route's check inside the callback, and a file that does not parse is refused with 409, never read as `[]` and replaced); the one direct writer is config-backup restore. The endpoint id `local-ollama` is reserved for the local model install (see Local model install). Pinned by `test/custom-model-hosts-writer.test.ts`. → [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); the one exception is `ownEnv`'s `homeEnvVar` entry, the session's OWN home path (absolute only: a `clis.json` override can name any variable there), which `setCustomModel()` puts back on clear and recovery clamps, since the selection overwrote the tmux `setenv` that held it. ⚠️ 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()`. ⚠️ Every write of `custom-model-hosts.json` goes through `mutateCustomModelHosts()` (`src/custom-model-hosts-writer.ts`: one at a time, temp file + rename, the route's check inside the callback, and a file that does not parse is refused with 409, never read as `[]` and replaced); the one direct writer is config-backup restore. The endpoint id `local-ollama` is reserved for the local model install (see Local model install). Pinned by `test/custom-model-hosts-writer.test.ts`. → [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)
|
⚠️ **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)
|
||||||
|
|
||||||
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for one row and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
|
**Local model recommendation** (`GET /api/local-model`, `src/local-model/`, `src/config/local-model-catalog.ts`, `src/types/local-model.ts`; EXPERIMENTAL): the server looks at the Mac it runs on and says which local coding model fits it and with which agent CLI. ⚠️ The READ side stays read-only: the probe, the catalogue and `recommend()` download nothing, install nothing and write nothing; everything that changes the machine is the install below, behind an explicit request. Apple silicon macOS only: anywhere else the answer is a `200` with verdict `none` (reasons `not-macos`, `intel`, `memory`, `disk`, `macos`, `no-harness`), and a failed probe is an answer, never a 500. The probe (`hardware.ts`) is ONE bounded async spawn of `/usr/sbin/sysctl` WITHOUT `-n`, parsed as `key: value` lines by the pure `parseSysctl()`, plus `statfs` of the data dir (probed bounded first, #516) and `node:os` fallbacks, cached 60 s; ⚠️ never `system_profiler`, never a second spawn, and `iogpu.wired_limit_mb` of 0 means not set. The catalogue is a compiled constant validated by a Zod schema: ⚠️ no override file and no runtime data, and a model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`, so an id can never name another registry host). ⚠️ A harness is a catalogue ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`) plus a `HarnessFact` built from the CLI registry at call time (`harness-facts.ts`); no code compares a CLI id (both id-branching guards scan these files). `recommend()` (`recommend.ts`) is pure. ⚠️ A model's need is its PEAK (`bytes + heldBytesPerToken * context + overheadBytes + peakExtraBytes`, measured on a Mac for two rows, the MLX builds of Qwen3.6 35B-A3B and Muse Glimmer 30B, and marked `estimated` on the others), and every row names its `runner` (`mlx` or `llamacpp`, the build a Mac really pulls) and carries figures of its OWN: computed from its model's config.json by that runner's rule (catalogue header, MEMORY), never borrowed from another row; `test/local-model-catalog.test.ts` recomputes them, and the `llamacpp` rows assume the runtime's `OLLAMA_KV_CACHE_TYPE=q8_0`, `LLAMA_ARG_CACHE_RAM=1024` and `LLAMA_ARG_CTX_CHECKPOINTS=8` (`runtimeEnv()`) and count what llama-server keeps beside the cache (8 context checkpoints of the recurrent or sliding-window state, and for a GGUF with a prediction layer one rollback copy of the recurrent state per drafted token and that layer's cache in f16), so change the two together. The need is judged against a budget that is `estimated` until the runtime ran and `measured` from then on (`RecommendOptions.measuredTotalBytes`). ⚠️ Ranking, lexicographic: tested on a Mac like this first, then speed `ok` before `unknown` before `slow`, then model rank, harness rank, larger context; the fit grade decides whether a row is listed and what it says, it does NOT sort, so a model is offered at the largest context it fits at. ⚠️ The answer lists EVERY model that fits, and the install accepts only a listed row: `MAX_ALTERNATIVES` is a bound above the catalogue's size (`test/local-model-catalog.test.ts` fails for a catalogue that outgrows it), never a way to shorten the list. ⚠️ `limited` is a statement about the MAC (no model with 12 billion parameters or more fits), judged before the disk filter and whatever the pick is; a small pick under `ok` carries `noteSmallPick`, and a model left out for disk space is named in `noteDiskDropped`, never dropped in silence. ⚠️ The word "Recommended" exists only in `headlineTested`, and the tag "Tested on a Mac like this" only on a row whose model has a `tested` entry for exactly this chip, memory size and harness. ⚠️ Speed is `ok`/`slow`/`unknown`, graded on the unrounded figure, and a chip in no chips row is `unknown`: never guess a bandwidth. ⚠️ ONE unit for every computed size a person reads: decimal GB text from `formatGb()` (`format.ts`), sent beside the bytes, and the browser never divides bytes; the one exception is the installed memory, shown as `memClassGb` (the size the Mac is sold as, 48 and not 51.5). ⚠️ Every sentence the server chooses is a `LOCAL_MODEL_LABELS`, `LOCAL_MODEL_TAG_LABELS` or `LOCAL_MODEL_INSTALL_LABELS` text with `{placeholder}` slots filled from `vars` (no apostrophe in a text: the zh-CN key is single-quoted), and each needs a zh-CN entry. Admin only in multi-user mode (403 before anything is probed). Tests: `test/local-model-*.test.ts`, `test/routes/local-model-routes.test.ts`. Window rules: `src/web/CLAUDE.md` (Run a model on this Mac). → `docs/api-reference.md` (Local model), `docs/versioning-policy.md`
|
||||||
|
|
||||||
**Local model install** (`POST /api/local-model/install|cancel|resume|runtime`, `DELETE /api/local-model`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalogue harness row, after the consent click that showed the literal command, audited (`cli_install`, `via: 'local-model'`); afterwards `forgetResolvedCli()` and a fresh lookup, and still missing is `harness-failed`. ⚠️ Connect writes ONE endpoint record under the RESERVED id `local-ollama` through `mutateCustomModelHosts()` and `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, remembering the flag's first value; a record with that id that is not ours is never overwritten, and while it is ours it is MANAGED: the endpoint routes refuse to edit or delete it and the discovery sweep skips it (the runtime's port has no login, so its `models` must stay the one model that was checked). ⚠️ The model digest is stored and read again at boot and before every START of a coding agent on that endpoint (`launchRefusalMessage()`): the two custom-model launch routes in `session-routes.ts` ask it before anything is written, the reboot-restore rebuild asks it before it builds (reason `local-model-not-ready`, left on offer), and `Session`'s launch guard (`setLaunchGuard()`, set in `setupSessionListeners()`) asks it right before a NEW agent process starts (a fresh pane, the respawn of a dead one, `restartCli()`), never for an attach to an agent that runs; a refusal is a `LaunchRefusedError` that never falls back to a direct PTY. A changed or missing model, a runtime Codeman did not start or that did not start, or another model id refuses the launch with one sentence. ⚠️ The connection is CLAIMED in the state file (`connection`, with the flag's value read by `inspectLocalEndpoint()`) BEFORE the endpoint record and the settings flag are written, so a state write that fails or a kill between the two never leaveLine truncated
|
**Local model install** (`POST /api/local-model/install|use|cancel|resume|runtime|agent`, `DELETE /api/local-model`, `DELETE /api/local-model/models`; `src/local-model/{install-job,runtime,ollama-client,connect,state-store,paths,developer-tools}.ts`; EXPERIMENTAL, admin only in multi-user mode on EVERY verb; guide `docs/local-models.md`): installs the chosen model on the Mac the server runs on and ends on a session of the coding agent running on it. ONE job of five idempotent steps (`runtime`, `model`, `verify`, `agent`, `connect`; `agent` only when the coding agent is missing), a pure reducer (`reduceInstallJob()`) over three tables in `install-job.ts` driven by one service per process (`getLocalModelService()`); the window polls the GET once a second, no SSE event. ⚠️ A LIBRARY with ONE model in use: the store may hold several models and `LocalModelStateFile.library` lists the ones that passed the check (written by the service when `verify` passes, carried by the reducer, dropped by `remove`; an `installed` file without it counts its one model, `libraryOf()`), but the endpoint record lists ONE model, the runtime holds one and only the state's model is ever launched: never widen the record to every checked model. `install` from `installed` adds a model (the earlier one stays: `replacedModelFor()` never names a library model), `use` switches to a checked one (a job of `USE_JOB_STEPS`, `verify` then `connect`; the stored digest is compared before anything is loaded, else `model-changed`), and both answer 409 `sessions-open` while a live session uses the endpoint; until such a job ends `installed`, no session starts (`launchRefusal()` answers `job-unfinished`). `DELETE /api/local-model/models` deletes ONE model by its NAME (`LOCAL_MODEL_ID_PATTERN`, never a path), only a name of the server's own listing, only through the runtime's own delete, never the model of the installation or of the current job (409 `model-active`), and it is no reducer event: its row is the `delete-model` action of `INSTALL_STATE_TABLE`. ⚠️ The view's `library` (state entries joined with the runtime's `/api/tags`: `ready`, `changed`, `missing`, `unverified`) and `store` (the blob folder's size, `measureModelStore()`) are cached (5 s, 30 s), refreshed in the background and thrown away by every verb and step that changes them (`forgetStore()`): never ask the runtime or the disk on the one-second poll, and a runtime that does not answer keeps the last list. ⚠️ Layout only through `localModelPaths()` (all `dataPath()`): `local-model/runtime/ollama-<version>/`, `local-model/models/`, `local-model/home/` (the runtime's PRIVATE HOME, so the user's `~/.ollama` is never touched) and the state file `local-model.json` (0600, ONE serialized writer, total reader, holds NO path); nothing is written or deleted outside `local-model/` except that file and the two writes of the connect step. ⚠️ The runtime pin (`OLLAMA_RUNTIME_PIN`: version, URL, size, sha256) is a COMPILED constant no file, setting or request may supply; the hash is compared BEFORE anything is unpacked. ⚠️ The runtime is a NON-detached child whose environment comes from the `runtimeEnv()` ALLOWLIST, never `process.env` (no `CODEMAN_*`, none of the user's `OLLAMA_*`); the port is chosen ONCE by binding (from 11435, never 11434) and stored; a listener on it is adopted or replaced only when its stored pid runs a binary under OUR runtime folder, anything else is `port-in-use`; started at boot when installed and enabled, max 3 restarts, SIGTERM at shutdown beside `stopDeepSeekWeb()`; under vitest nothing spawns or fetches. ⚠️ Consent is bound to the size: `acknowledgedBytes` must equal the server's own total (`Candidate.installBytes`) or the answer is 409 `size-changed`; the model, context and CLI must be a row the current recommendation offers (400). ⚠️ ONE job at a time (409 `job-running`); a job a restart interrupted comes back `paused` and NEVER continues without a click (so `shutdown()` writes nothing), and an interrupted step is never recorded as failed. ⚠️ An event the transition table does not list is refused (same object back, 409 `wrong-state`): add a row to the table, never a special case. ⚠️ The budget is MEASURED from the runtime's "inference compute" log line and the model judged against it BEFORE it is downloaded. ⚠️ The pull stream is parsed incrementally (about 1.4 million lines for 23 GB): never buffer it, never write state per line. ⚠️ A failure is a code with ONE next step (`LOCAL_MODEL_ERROR_NEXT`), never a bare "failed"; a probe that ended on its token limit is `probe-unfinished`, not `no-tool-call`. ⚠️ The coding agent is installed through the shared runner `src/cli-install-runner.ts` (extracted from `cli-registry-routes.ts`, ONE install slot per CLI for both callers) with `stockInstallCommand()`, the COMPILED stock command, never the `clis.json`-merged one (this path has no `cliManagementEnabled` gate); only for a catalLine truncated
|
||||||
|
|
||||||
**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`
|
**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`
|
||||||
|
|
||||||
@@ -210,13 +214,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Reboot restore** (`src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first `await`, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, `rearmAutoResumeSchedule: false`, and pass `nameSource` through. ⚠️ Undo a failed rebuild with `discardPartiallyBuiltSession()`, NEVER `cleanupSession()`. Claude-mode only, never remote/docker. Tests: `test/reboot-restore.test.ts`. → [architecture-invariants#reboot-restore](docs/architecture-invariants.md#reboot-restore)
|
**Reboot restore** (`src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first `await`, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, `rearmAutoResumeSchedule: false`, and pass `nameSource` through. ⚠️ Undo a failed rebuild with `discardPartiallyBuiltSession()`, NEVER `cleanupSession()`. Claude-mode only, never remote/docker. Tests: `test/reboot-restore.test.ts`. → [architecture-invariants#reboot-restore](docs/architecture-invariants.md#reboot-restore)
|
||||||
|
|
||||||
**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory queue fed by `/api/hook-event` (answerable items claude-only; DeepSeek's and codex's screen-read ones are alert-only `answerInTerminal`), at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox)
|
**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory queue fed by `/api/hook-event` (answerable items claude-only; DeepSeek's, omp's hook-module ones and codex's screen-read ones are alert-only `answerInTerminal`; an omp item takes its frame ONCE as `context` and has no `capture`, since the re-read parses a numbered list in the agent's own output as options and cleared the alert when it scrolled away, so only the module's `elicitation_complete` clears it), at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox)
|
||||||
|
|
||||||
**Read My Mind intent profiles** (`readMyMindEnabled`, SYNCED, default OFF; `docs/readmymind-plan.md`): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (`transcript:user_prompt`), never the input paths; the listener must stay inside `startTranscriptWatcher()`'s `if (!watcher)` block. Store `src/intent-store.ts` → `intents.json`, ⚠️ written 0600 tmp+rename and never fed to `/api/search` (prompts carry secrets). Routes in `readmymind-routes.ts` (ownership via `findSessionOrFail` WITH `req`); predictor = pure `readmymind-context.ts` + IO in `readmymind-collectors.ts` + `readmymind-predictor.ts`, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/`textContent` ONLY and nothing auto-sends, ever. Frontend `readmymind-ui.js`. → [architecture-invariants#read-my-mind-intent-profiles](docs/architecture-invariants.md#read-my-mind-intent-profiles)
|
**Read My Mind intent profiles** (`readMyMindEnabled`, SYNCED, default OFF; `docs/readmymind-plan.md`): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (`transcript:user_prompt`), never the input paths; the listener must stay inside `startTranscriptWatcher()`'s `if (!watcher)` block. Store `src/intent-store.ts` → `intents.json`, ⚠️ written 0600 tmp+rename and never fed to `/api/search` (prompts carry secrets). Routes in `readmymind-routes.ts` (ownership via `findSessionOrFail` WITH `req`); predictor = pure `readmymind-context.ts` + IO in `readmymind-collectors.ts` + `readmymind-predictor.ts`, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/`textContent` ONLY and nothing auto-sends, ever. Frontend `readmymind-ui.js`. → [architecture-invariants#read-my-mind-intent-profiles](docs/architecture-invariants.md#read-my-mind-intent-profiles)
|
||||||
|
|
||||||
**Agent inbox** (discussion #558; persistence `agentInboxPersistEnabled`, SYNCED, default OFF): per-session mailbox in `web/agent-inbox.ts`, routes `routes/inbox-routes.ts`, CLI `codeman agent post|inbox|ack`. Never writes to a pane, never in `toState()` (counts via `GET /api/agent-inbox/summary`). ⚠️ Read until ack by id; seen/ack only for the receiver resolved via `resolveParentSessionId()` (ack from anyone else = 403); `?wait=` parks while nothing is UNSEEN; full = 409, never drop the oldest; dropped on delete (`killMux`) only, boot prune only after a successful restore. → [architecture-invariants#agent-inbox](docs/architecture-invariants.md#agent-inbox)
|
**Agent inbox** (discussion #558; persistence `agentInboxPersistEnabled`, SYNCED, default OFF): per-session mailbox in `web/agent-inbox.ts`, routes `routes/inbox-routes.ts`, CLI `codeman agent post|inbox|ack`. Never writes to a pane, never in `toState()` (counts via `GET /api/agent-inbox/summary`). ⚠️ Read until ack by id; seen/ack only for the receiver resolved via `resolveParentSessionId()` (ack from anyone else = 403); `?wait=` parks while nothing is UNSEEN; full = 409, never drop the oldest; dropped on delete (`killMux`) only, boot prune only after a successful restore. → [architecture-invariants#agent-inbox](docs/architecture-invariants.md#agent-inbox)
|
||||||
|
|
||||||
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI `/voice` backend) instead of Deepgram; the browser captures, `src/web/voice-stream.ts` relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (`src/claude-credentials.ts`): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; `voice-pcm-worklet.js` borrows voice-input.js's `?v=` token, so edit the two together. ⚠️ Claude transcript frames are cumulative: replace, never append. → [architecture-invariants#voice-dictation-via-claude](docs/architecture-invariants.md#voice-dictation-via-claude)
|
**Voice dictation via Claude** (`claudeVoiceEnabled`, SYNCED, default OFF): the mic transcribes through this machine's Claude Code login (the CLI `/voice` backend) instead of Deepgram; the browser captures, `src/web/voice-stream.ts` relays to Anthropic. ⚠️ The OAuth token never reaches the page. ⚠️ Credentials are READ-ONLY (`src/claude-credentials.ts`): never refresh them (it rotates the refresh token and can sign the user out of their CLI). ⚠️ Capture must be linear16/16 kHz/mono via an AudioWorklet; `voice-pcm-worklet.js` borrows voice-input.js's `?v=` token, so edit the two together. ⚠️ Claude transcript frames are cumulative within each utterance: replace interims, append endpoint finals once, and keep recording until Stop or the configured silence deadline. → [architecture-invariants#voice-dictation-via-claude](docs/architecture-invariants.md#voice-dictation-via-claude)
|
||||||
|
|
||||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||||
|
|
||||||
@@ -241,7 +245,9 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
|||||||
|
|
||||||
**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, so the allowlist, blocked-tree and ownership rules below do not apply to it). A Browse fills its form field; the Path key 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, Create New's custom folder and Clone Repo's parent folder have 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 > Create New with no custom folder 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)
|
**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, so the allowlist, blocked-tree and ownership rules below do not apply to it). A Browse fills its form field; the Path key 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, Create New's custom folder and Clone Repo's parent folder have 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 > Create New with no custom folder 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, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) 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`
|
**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, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; dotenv is the one sensitive family it opens (`allowDotenv`, because the plain preview already serves it), so every other credential pattern, a dotenv file inside a blocked tree and the data dir's own `.env` still refuse; 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 delete** (salvaged from #529; `DELETE /api/sessions/:id/files?path=`, the × on a file row of the Files panel, tree and search results): removes ONE regular file and is the **only file surface that deletes**. It takes the edit route's confinement and guards whole (ownership via `findSessionOrFail`, `validateLocalSessionFilePath` behind `withGuardedOutsideWorktree`, so an absolute path in a followed outside worktree obeys the same rules, sensitive/blocked/`.git` 403), and both spellings are checked, the realpath and the path as named. ⚠️ **A symbolic link is refused (400), never followed**: the confinement check returns the REALPATH, i.e. the link's target, so an `unlink` of it deleted `CLAUDE.md` for `AGENTS.md -> CLAUDE.md` and left the link dangling. The final component is `lstat`ed on the path AS NAMED, only a plain file goes on, the inode is compared again before `unlink`, and `unlink` never follows a final link. A dangling link fails realpath and reads as 404. ⚠️ Remote sessions are refused before any path check (a same-named local folder would take the delete), and so are adopted ones (the `/tmp` file-surface exception is not copied to a new surface); Docker workspaces are allowed like the edit route. The workspace (and the outside worktree root) is probed bounded first (#516, `422` on a dead mount). ⚠️ The button is bound from the row's escaped `data-path` in the `.file-tree-item` loops, NEVER an inline `onclick` (`encodeURIComponent` leaves `'` alone, so `x'-alert(1)-'.txt` ran script); it asks with `confirm()`, reloads through `_reloadFileBrowserKeepingQuery()` so a search re-runs, stays visible under `@media (hover: none)`, and is not drawn for remote or adopted sessions. Tests: `test/routes/file-delete-routes.test.ts`, `test/file-delete-ui.test.ts`. → [architecture-invariants#files-panel-delete](docs/architecture-invariants.md#files-panel-delete)
|
||||||
|
|
||||||
**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. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget, where a folder's own files count as one share beside the subfolders it just found (`walkTurnEnd()`). ⚠️ That budget caps what is LISTED, never what is read: a read keeps only what the listing can use (`readWalkFolder()`) and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders, or one GET reads the whole workspace into memory. ⚠️ A folder below the walk's root is probed (#516) before its `readdir`, and anything but `directory` is skipped unflagged. ⚠️ A directory has its node before it is read, so every cursor still queued or unread when the walk ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
**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. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget, where a folder's own files count as one share beside the subfolders it just found (`walkTurnEnd()`). ⚠️ That budget caps what is LISTED, never what is read: a read keeps only what the listing can use (`readWalkFolder()`) and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders, or one GET reads the whole workspace into memory. ⚠️ A folder below the walk's root is probed (#516) before its `readdir`, and anything but `directory` is skipped unflagged. ⚠️ A directory has its node before it is read, so every cursor still queued or unread when the walk ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||||
|
|
||||||
@@ -265,7 +271,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
|||||||
|
|
||||||
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview/<cap>/...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview/<cap>/...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
||||||
|
|
||||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
|
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. ⚠️ `settings.json` is ONE server-wide file, so every write to it is admin-only (`PUT /api/settings`, `/api/execution/model-config`, `/api/cases/order`, plus `PUT /api/config`: 403 `FORBIDDEN` before parsing), a non-admin's quick-start/Ralph start skips `lastUsedCase`, its GET hides `voiceSettings.apiKey`/`caseOrder`/`lastUsedCase`/`lastPickedCase`/`caseSettings` from a non-admin (`settingsForNonAdmin`), and the browser skips those writes for a non-admin via `_settingsReadOnly()` (settings-ui.js; also true once a write came back 403 `FORBIDDEN`, since `/api/me` can be slow or fail; their run mode and respawn presets stay per-device, `_applyOwnSettings`) (`test/routes/system-routes-settings-admin-only.test.ts`). System ops are admin-only too (`/api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate`, `/api/cleanup-state`), and a non-admin's `/api/auth/revoke` revokes only their own logins (`test/routes/system-routes-system-ops-admin-only.test.ts`). → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
|
||||||
|
|
||||||
**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
|
**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
|
||||||
|
|
||||||
@@ -328,7 +334,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
|||||||
|
|
||||||
## State Files
|
## 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), `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), `local-model.json` (local model install: state, step, model, port, pid, digest, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `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`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four 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), `local-model.json` (local model install: state, step, model, port, pid, digest, the checked models of the library, the previous `customModelEndpointsEnabled` value; mode 0600, holds no path, not part of a config backup) + `local-model/` (the pinned runtime, the model store and the runtime's private HOME, removed by `DELETE /api/local-model`), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `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`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<h2 align="center">Mission control for AI coding agents</h2>
|
<h2 align="center">Mission control for AI coding agents</h2>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • Terminal - One Dashboard • Any Device</em>
|
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • jcode • Terminal - One Dashboard • Any Device</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
|
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, OMP, or GitHub Copilot CLI inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, OMP, GitHub Copilot CLI, or jcode inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
||||||
|
|
||||||
Get started in one line (macOS & Linux, Windows via WSL):
|
Get started in one line (macOS & Linux, Windows via WSL):
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ The installer asks before every system change, and re-running the same line upda
|
|||||||
|
|
||||||
On a Mac you can skip the terminal: [download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg), and the app installs Codeman for you, opens the one already running, or connects to one on another machine. More in [On a Mac: the Codeman app](#on-a-mac-the-codeman-app).
|
On a Mac you can skip the terminal: [download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg), and the app installs Codeman for you, opens the one already running, or connects to one on another machine. More in [On a Mac: the Codeman app](#on-a-mac-the-codeman-app).
|
||||||
|
|
||||||
- **One dashboard, ten CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, or GitHub Copilot](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
|
- **One dashboard, eleven CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, GitHub Copilot, or jcode](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
|
||||||
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
||||||
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
||||||
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
|
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
|
||||||
@@ -79,7 +79,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
|
|||||||
- **No administrator rights needed.** On an account without `sudo`, one whose `sudo` rights the installer cannot confirm (outside the `sudo`, `wheel` and `admin` groups, with a password still required) or a Mac account that is not an administrator and has no Homebrew it can write to, the installer downloads Node.js from nodejs.org and the tmux project's own build into `~/.codeman/runtime`, each checked against its SHA-256, and takes the source as an archive when there is no working git. `--no-admin` forces that route and `CODEMAN_NO_ADMIN=0` turns it off. Two limits: Linux needs make, a C++ compiler and python3 to be there already, and the tmux download needs macOS 15 or newer. Details in the [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights).
|
- **No administrator rights needed.** On an account without `sudo`, one whose `sudo` rights the installer cannot confirm (outside the `sudo`, `wheel` and `admin` groups, with a password still required) or a Mac account that is not an administrator and has no Homebrew it can write to, the installer downloads Node.js from nodejs.org and the tmux project's own build into `~/.codeman/runtime`, each checked against its SHA-256, and takes the source as an archive when there is no working git. `--no-admin` forces that route and `CODEMAN_NO_ADMIN=0` turns it off. Two limits: Linux needs make, a C++ compiler and python3 to be there already, and the tmux download needs macOS 15 or newer. Details in the [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights).
|
||||||
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
||||||
|
|
||||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), or [GitHub Copilot CLI](https://github.com/github/copilot-cli) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the ten is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
|
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), [GitHub Copilot CLI](https://github.com/github/copilot-cli), or [jcode](https://jcode.sh) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the eleven is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codeman web
|
codeman web
|
||||||
@@ -108,7 +108,7 @@ The first run asks three questions (data folder, port, password; Enter takes the
|
|||||||
|
|
||||||
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
|
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
|
||||||
|
|
||||||
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
|
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while they work, blinks when one waits for you and flashes when one needs permission, and each state can take a style of your own. It stays off until you turn it on. To turn the logo off, dim it or give it effects the rest of the time, there's [LogoLight](https://github.com/Ark0N/LogoLight) (`brew install --cask ark0n/tap/logolight`), which steps aside while Codeman blinks the logo. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
|
||||||
|
|
||||||
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
|
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
|
||||||
|
|
||||||
@@ -290,7 +290,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
|
|||||||
| Field | What it does |
|
| Field | What it does |
|
||||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
|
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
|
||||||
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, `Copilot`, or `Terminal` (plain shell). |
|
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, `Copilot`, `jcode`, or `Terminal` (plain shell). |
|
||||||
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
|
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
|
||||||
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
|
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
|
||||||
|
|
||||||
@@ -460,6 +460,8 @@ The title is templated into the served HTML on first byte, so it's correct from
|
|||||||
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
|
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
|
||||||
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
||||||
|
|
||||||
|
Both are opt-in per session, and the thresholds go up to the model's window. Claude sessions are measured by the context size on the statusline, cache reads included; everything else by Codeman's token counter.
|
||||||
|
|
||||||
### Tab Alerts
|
### Tab Alerts
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -495,7 +497,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
|||||||
- **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
|
- **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.
|
- **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.
|
||||||
- **Open a tmux session you started by hand**: a tmux session started outside Codeman shows up on the home screen, on this machine automatically and in your Docker cases' containers or on your saved SSH hosts when you scan for them. One click opens it as a tab. Closing the tab only detaches, and Codeman will not respawn or relaunch an agent it did not start. See [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman).
|
- **Open a tmux session you started by hand**: a tmux session started outside Codeman shows up on the home screen, on this machine automatically and in your Docker cases' containers or on your saved SSH hosts when you scan for them. One click opens it as a tab. Closing the tab only detaches, and Codeman will not respawn or relaunch an agent it did not start. See [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman).
|
||||||
- **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)
|
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, **GitHub Copilot CLI**, or **jcode** 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), [`docs/copilot-integration.md`](docs/copilot-integration.md) and [`docs/jcode-integration.md`](docs/jcode-integration.md) (jcode takes no env prefix: its tools run in one shared daemon)
|
||||||
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||||
- **A model on this Mac** _(experimental, new in 1.50.0)_: on a Mac with Apple silicon, **Run a local model** on the welcome screen shows which coding model fits, installs it with its runtime and a coding agent, and starts a session on it: no account, no API key, and the prompts and files of that session go to a model on this Mac, not to a cloud provider. Then mix cloud and local: through the [codeman skill](#the-agent-skill-start-here) a Claude Code session hands work to that model (`spawn_worker <name> local`, `sendmark`) and reads the answer back, so Claude plans and reviews in the cloud while the Mac does the bulk: summaries, first drafts, files that should stay here. See [`docs/local-models.md`](docs/local-models.md)
|
- **A model on this Mac** _(experimental, new in 1.50.0)_: on a Mac with Apple silicon, **Run a local model** on the welcome screen shows which coding model fits, installs it with its runtime and a coding agent, and starts a session on it: no account, no API key, and the prompts and files of that session go to a model on this Mac, not to a cloud provider. Then mix cloud and local: through the [codeman skill](#the-agent-skill-start-here) a Claude Code session hands work to that model (`spawn_worker <name> local`, `sendmark`) and reads the answer back, so Claude plans and reviews in the cloud while the Mac does the bulk: summaries, first drafts, files that should stay here. See [`docs/local-models.md`](docs/local-models.md)
|
||||||
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||||
@@ -977,7 +979,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
|
|
||||||
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp/copilot) have no transcript:
|
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp/copilot/jcode) have no transcript:
|
||||||
# read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput
|
# read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput
|
||||||
# is empty for every tmux-backed (i.e. every interactive) session. tail counts
|
# is empty for every tmux-backed (i.e. every interactive) session. tail counts
|
||||||
# BYTES, and what comes back is terminal data, ANSI included.
|
# BYTES, and what comes back is terminal data, ANSI included.
|
||||||
@@ -1218,7 +1220,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph External["External"]
|
subgraph External["External"]
|
||||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
|
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot / jcode</small>"]
|
||||||
BG["Background Agents<br/><small>(Task tool)</small>"]
|
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+9
-9
@@ -5,7 +5,7 @@
|
|||||||
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • 终端 —— 统一仪表盘 • 任意设备</em>
|
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • jcode • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||||
|
|
||||||
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness、OMP 或 GitHub Copilot CLI,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
|
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness、OMP、GitHub Copilot CLI 或 jcode,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
|
||||||
|
|
||||||
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ codeman web
|
|||||||
|
|
||||||
在 Mac 上可以不用终端:[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg),这个应用会替你安装 Codeman、打开已在运行的 Codeman,或者连接另一台机器上的 Codeman。详见[在 Mac 上:Codeman 应用](#在-mac-上codeman-应用)。
|
在 Mac 上可以不用终端:[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg),这个应用会替你安装 Codeman、打开已在运行的 Codeman,或者连接另一台机器上的 Codeman。详见[在 Mac 上:Codeman 应用](#在-mac-上codeman-应用)。
|
||||||
|
|
||||||
- **一个仪表盘,十个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP 或 GitHub Copilot](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
|
- **一个仪表盘,十一个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP、GitHub Copilot 或 jcode](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
|
||||||
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
|
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
|
||||||
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
|
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
|
||||||
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
|
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
|
||||||
@@ -81,7 +81,7 @@ curl -fsSL https://getcodeman.com/install | bash
|
|||||||
- **无需管理员权限。** 在没有 `sudo` 的账户、安装器无法确认 `sudo` 权限的账户(不在 `sudo`、`wheel`、`admin` 组内,且 `sudo` 需要密码)或不是管理员、也没有可写入的 Homebrew 的 Mac 账户上,安装器会把 nodejs.org 的 Node.js 和 tmux 项目自己的构建下载到 `~/.codeman/runtime`,两者都会校验 SHA-256;没有可用的 git 时,则以归档包的方式获取源码。`--no-admin` 强制走这条路线,`CODEMAN_NO_ADMIN=0` 则将其关闭。两个限制:Linux 上必须已有 make、C++ 编译器和 python3,tmux 下载需要 macOS 15 或更新版本。详见 [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights)。
|
- **无需管理员权限。** 在没有 `sudo` 的账户、安装器无法确认 `sudo` 权限的账户(不在 `sudo`、`wheel`、`admin` 组内,且 `sudo` 需要密码)或不是管理员、也没有可写入的 Homebrew 的 Mac 账户上,安装器会把 nodejs.org 的 Node.js 和 tmux 项目自己的构建下载到 `~/.codeman/runtime`,两者都会校验 SHA-256;没有可用的 git 时,则以归档包的方式获取源码。`--no-admin` 强制走这条路线,`CODEMAN_NO_ADMIN=0` 则将其关闭。两个限制:Linux 上必须已有 make、C++ 编译器和 python3,tmux 下载需要 macOS 15 或更新版本。详见 [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights)。
|
||||||
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
||||||
|
|
||||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)、[OMP](https://github.com/can1357/oh-my-pi) 或 [GitHub Copilot CLI](https://github.com/github/copilot-cli)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
|
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)、[OMP](https://github.com/can1357/oh-my-pi)、[GitHub Copilot CLI](https://github.com/github/copilot-cli) 或 [jcode](https://jcode.sh)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十一个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codeman web
|
codeman web
|
||||||
@@ -110,7 +110,7 @@ bash docker/Start-Codeman.sh
|
|||||||
|
|
||||||
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
|
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
|
||||||
|
|
||||||
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
|
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志显示智能体在做的事:工作时缓慢呼吸,有智能体等你回复时闪烁,需要权限时快闪三下;每种状态也可以换成你自己选的样式。在你打开之前,它保持关闭。想在其余时间关掉标志、调暗它或给它加上效果,可以用 [LogoLight](https://github.com/Ark0N/LogoLight)(`brew install --cask ark0n/tap/logolight`),Codeman 让标志闪烁时它会自动让开。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
|
||||||
|
|
||||||
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
|
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
|
|||||||
| 字段 | 作用 |
|
| 字段 | 作用 |
|
||||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。**Add Case** 可以从零创建、链接一个已有文件夹,或把一个 GitHub 仓库直接克隆成 case(**Clone Repo**)。 |
|
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。**Add Case** 可以从零创建、链接一个已有文件夹,或把一个 GitHub 仓库直接克隆成 case(**Clone Repo**)。 |
|
||||||
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP`、`Copilot` 或 `Terminal`(普通 shell)。 |
|
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP`、`Copilot`、`jcode` 或 `Terminal`(普通 shell)。 |
|
||||||
| **模型** | 每会话模型(App Settings → Models → New Claude sessions)。软默认值 —— 会话内 `/model` 依然有效。 |
|
| **模型** | 每会话模型(App Settings → Models → New Claude sessions)。软默认值 —— 会话内 `/model` 依然有效。 |
|
||||||
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
|
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
|
||||||
|
|
||||||
@@ -497,7 +497,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
|||||||
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
|
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
|
||||||
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
|
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
|
||||||
- **打开你手动启动的 tmux 会话**:在 Codeman 之外启动的 tmux 会话会出现在主屏幕上:本机的会自动列出,Docker case 容器内和已保存 SSH 主机上的会在你扫描时列出。点一下即可作为标签页打开。关闭标签页只会分离,Codeman 不会重生或重启并非由它启动的智能体。详见 [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman)。
|
- **打开你手动启动的 tmux 会话**:在 Codeman 之外启动的 tmux 会话会出现在主屏幕上:本机的会自动列出,Docker case 容器内和已保存 SSH 主机上的会在你扫描时列出。点一下即可作为标签页打开。关闭标签页只会分离,Codeman 不会重生或重启并非由它启动的智能体。详见 [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman)。
|
||||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP** 或 **GitHub Copilot CLI**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*`、`OMP_*` 与 `COPILOT_*`)。详见 [`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) 与 [`docs/copilot-integration.md`](docs/copilot-integration.md)
|
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP**、**GitHub Copilot CLI** 或 **jcode**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*`、`OMP_*` 与 `COPILOT_*`)。详见 [`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)、[`docs/copilot-integration.md`](docs/copilot-integration.md) 与 [`docs/jcode-integration.md`](docs/jcode-integration.md)(jcode 不接受环境变量前缀:它的工具运行在一个共享的守护进程里)
|
||||||
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||||
- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
|
- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||||
- **Docker 会话** —— 在隔离且加固的容器中运行 case。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一 case 的多个会话共享一个容器,也可以把 case 挂到你已经在跑的容器上;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
|
- **Docker 会话** —— 在隔离且加固的容器中运行 case。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一 case 的多个会话共享一个容器,也可以把 case 挂到你已经在跑的容器上;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||||
@@ -971,7 +971,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
|
|
||||||
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp/copilot)没有 transcript,读终端。
|
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp/copilot/jcode)没有 transcript,读终端。
|
||||||
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
|
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
|
||||||
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
||||||
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
||||||
@@ -1192,7 +1192,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph External["外部"]
|
subgraph External["外部"]
|
||||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
|
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot / jcode</small>"]
|
||||||
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -305,5 +305,34 @@
|
|||||||
"docsUrl": "https://github.com/github/copilot-cli"
|
"docsUrl": "https://github.com/github/copilot-cli"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "jcode",
|
||||||
|
"label": "jcode",
|
||||||
|
"shortBadge": "JC",
|
||||||
|
"enabled": true,
|
||||||
|
"order": 110,
|
||||||
|
"kind": "agent",
|
||||||
|
"discovery": {
|
||||||
|
"binaries": [
|
||||||
|
"jcode"
|
||||||
|
],
|
||||||
|
"searchDirs": [
|
||||||
|
"~/.local/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"~/bin"
|
||||||
|
],
|
||||||
|
"install": {
|
||||||
|
"command": {
|
||||||
|
"linux": "curl -fsSL https://jcode.sh/install | bash",
|
||||||
|
"darwin": "curl -fsSL https://jcode.sh/install | bash"
|
||||||
|
},
|
||||||
|
"docsUrl": "https://jcode.sh/docs",
|
||||||
|
"agentImageLayer": {
|
||||||
|
"kind": "dedicated",
|
||||||
|
"reason": "Not on npm: jcode ships a standalone Rust binary through jcode.sh/install, whose launcher is a script that execs the binary beside it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+16
-13
@@ -1,16 +1,19 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||||
"entry": [
|
"workspaces": {
|
||||||
"scripts/*.mjs",
|
".": {
|
||||||
"scripts/*.js",
|
"entry": [
|
||||||
"scripts/watch-subagents.ts",
|
"scripts/*.{mjs,js,mts,ts}",
|
||||||
"scripts/remotion/Root.tsx",
|
"scripts/dev/*.mjs",
|
||||||
"scripts/remotion/index.ts",
|
"scripts/remotion/Root.tsx",
|
||||||
"test/**/*.test.ts",
|
"scripts/remotion/index.ts",
|
||||||
"test/mobile/vitest.config.ts",
|
"test/**/*.test.ts"
|
||||||
"test/**/*.mjs"
|
],
|
||||||
],
|
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx,mjs,js,mts}", "!scripts/remotion/**", "test/**/*.{ts,mjs}"]
|
||||||
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx,mjs,js}", "test/**/*.{ts,mjs}"],
|
}
|
||||||
|
},
|
||||||
"ignoreExportsUsedInFile": true,
|
"ignoreExportsUsedInFile": true,
|
||||||
"ignoreDependencies": ["@remotion/cli", "@remotion/transitions", "esbuild", "agent-browser"]
|
"ignoreDependencies": ["@remotion/cli", "@remotion/transitions"],
|
||||||
|
"ignoreBinaries": ["ffprobe", "ffmpeg", "wslpath", "vm_stat", "jq", "codex", "script", "mkfifo"],
|
||||||
|
"tags": ["-knipignore"]
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "io.github.Ark0N/codeman",
|
"name": "io.github.Ark0N/codeman",
|
||||||
"title": "Codeman",
|
"title": "Codeman",
|
||||||
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
|
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"websiteUrl": "https://getcodeman.com",
|
"websiteUrl": "https://getcodeman.com",
|
||||||
"repository": {
|
"repository": {
|
||||||
"url": "https://github.com/Ark0N/Codeman",
|
"url": "https://github.com/Ark0N/Codeman",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
{
|
{
|
||||||
"registryType": "npm",
|
"registryType": "npm",
|
||||||
"identifier": "aicodeman",
|
"identifier": "aicodeman",
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"transport": {
|
"transport": {
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
export const BROWSER_TEST_GLOBS = [
|
export const BROWSER_TEST_GLOBS = [
|
||||||
'test/wrapped-file-paths.browser.test.ts',
|
'test/wrapped-file-paths.browser.test.ts',
|
||||||
'test/command-palette-agent-arrows.browser.test.ts',
|
'test/command-palette-agent-arrows.browser.test.ts',
|
||||||
|
'test/voice-input.browser.test.ts',
|
||||||
'test/tmux-capture-sgr.browser.test.ts',
|
'test/tmux-capture-sgr.browser.test.ts',
|
||||||
'test/tab-rail-resize.browser.test.ts',
|
'test/tab-rail-resize.browser.test.ts',
|
||||||
'test/tab-activation.browser.test.ts',
|
'test/tab-activation.browser.test.ts',
|
||||||
@@ -51,6 +52,7 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/doctor-settings.browser.test.ts',
|
'test/doctor-settings.browser.test.ts',
|
||||||
'test/git-status.browser.test.ts',
|
'test/git-status.browser.test.ts',
|
||||||
'test/edit-history.browser.test.ts',
|
'test/edit-history.browser.test.ts',
|
||||||
|
'test/sync-case-state.browser.test.ts',
|
||||||
'test/split-pane-orchestration.browser.test.ts',
|
'test/split-pane-orchestration.browser.test.ts',
|
||||||
'test/split-pane-auto-collapse.browser.test.ts',
|
'test/split-pane-auto-collapse.browser.test.ts',
|
||||||
'test/spreadsheet-preview.browser.test.ts',
|
'test/spreadsheet-preview.browser.test.ts',
|
||||||
@@ -62,6 +64,8 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/phone-press-feedback.browser.test.ts',
|
'test/phone-press-feedback.browser.test.ts',
|
||||||
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
|
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
|
||||||
'test/local-model-ui.browser.test.ts',
|
'test/local-model-ui.browser.test.ts',
|
||||||
|
'test/home-breakpoint-swap.browser.test.ts',
|
||||||
|
'test/log-viewer-reconnect.browser.test.ts',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -203,6 +203,21 @@ RUN curl -fsSL https://omp.sh/install | sh \
|
|||||||
&& rm -f /root/.local/bin/omp \
|
&& rm -f /root/.local/bin/omp \
|
||||||
&& omp --version
|
&& omp --version
|
||||||
|
|
||||||
|
# jcode is NOT on npm: a standalone Rust binary via jcode.sh's installer, which
|
||||||
|
# writes $HOME/.jcode/builds/versions/<v>/ and links $HOME/.local/bin/jcode to it.
|
||||||
|
# The linked `jcode` is a small sh LAUNCHER that resolves its own path and execs
|
||||||
|
# the `jcode-<os>-<arch>.bin` beside it, so (unlike grok/omp above) the whole
|
||||||
|
# version directory is copied, to /opt/jcode, and /usr/local/bin/jcode links the
|
||||||
|
# launcher there. Root's ~/.jcode is dropped in the same layer so the image does
|
||||||
|
# not carry the ~160MB binary twice. JCODE_SKIP_SERVER_RELOAD: there is no jcode
|
||||||
|
# daemon at build time to hand off to.
|
||||||
|
RUN curl -fsSL https://jcode.sh/install | JCODE_SKIP_SERVER_RELOAD=1 bash \
|
||||||
|
&& mkdir -p /opt/jcode \
|
||||||
|
&& cp -a "$(dirname "$(readlink -f /root/.local/bin/jcode)")/." /opt/jcode/ \
|
||||||
|
&& ln -sfn /opt/jcode/jcode /usr/local/bin/jcode \
|
||||||
|
&& rm -rf /root/.jcode /root/.local/bin/jcode \
|
||||||
|
&& jcode --version
|
||||||
|
|
||||||
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
|
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
|
||||||
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
|
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
|
||||||
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
|
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
|
||||||
|
|||||||
+87
-28
@@ -211,11 +211,15 @@ a genuine tunnel failure looks like, and `204` cannot carry `waitedMs` / `status
|
|||||||
`limitPaused`. Reserve error handling for the four codes in the table below.
|
`limitPaused`. Reserve error handling for the four codes in the table below.
|
||||||
|
|
||||||
**2. `stop` fires only for `claude` (and `deepseek`) sessions, `blocked` also for
|
**2. `stop` fires only for `claude` (and `deepseek`) sessions, `blocked` also for
|
||||||
`codex`.** Both come from Claude Code hooks, and no other mode installs them: `shell`
|
`codex` and a local `omp` session.** Both come from Claude Code hooks, and no other mode
|
||||||
runs no agent, and the external CLIs render their own TUIs and post no hooks
|
installs them: `shell` runs no agent, and the external CLIs render their own TUIs and post
|
||||||
(`deepseek`'s status bridge reports the same two, see its own docs). The one exception
|
no hooks (`deepseek`'s status bridge reports the same two, see its own docs). There are two
|
||||||
is `blocked` on `codex`: Codeman reads codex's approval, question and trust dialogs off
|
exceptions, both for `blocked` alone. On `codex`, Codeman reads codex's approval, question
|
||||||
its screen and raises `blocked` for them (see [Signals](#signals)). For every other mode
|
and trust dialogs off its screen and raises `blocked` for them. On `omp`, Codeman installs
|
||||||
|
a hook module into the session's workspace that forwards omp's own tool approval events
|
||||||
|
(a local session only: nothing is installed for a remote, Docker or adopted one, where
|
||||||
|
`blocked` stays a 400, and omp asks nothing while its `tools.approvalMode` is the default
|
||||||
|
`yolo`). See [Signals](#signals). For every other mode
|
||||||
only `idle`, `working` and `exit` are accepted. `exit` is dependable everywhere; `idle` and
|
only `idle`, `working` and `exit` are accepted. `exit` is dependable everywhere; `idle` and
|
||||||
`working` are screen-checked for `opencode`, `gemini`, `pi`, `omp` and `antigravity` (as for `claude` and
|
`working` are screen-checked for `opencode`, `gemini`, `pi`, `omp` and `antigravity` (as for `claude` and
|
||||||
`codex`) and not guaranteed elsewhere: see the caveats under [Signals](#signals) before
|
`codex`) and not guaranteed elsewhere: see the caveats under [Signals](#signals) before
|
||||||
@@ -249,7 +253,7 @@ contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send
|
|||||||
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`, `antigravity`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`); an `antigravity` session waiting on a shell-command approval stays `working` until it is answered. `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) |
|
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`, `antigravity`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`); an `antigravity` session waiting on a shell-command approval stays `working` until it is answered. `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) |
|
||||||
| `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi`, `omp` and `antigravity` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph |
|
| `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi`, `omp` and `antigravity` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph |
|
||||||
| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only |
|
| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only |
|
||||||
| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below |
|
| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen; on `omp`, its own tool approval event, forwarded by the hook module Codeman installs | `claude`, `deepseek`, `codex` and a local `omp` session; on `claude` rarer than it looks: see below |
|
||||||
| `exit` | no process is behind the session | every mode |
|
| `exit` | no process is behind the session | every mode |
|
||||||
|
|
||||||
`stop` is the signal to orchestrate on where it exists; `idle` is a heuristic
|
`stop` is the signal to orchestrate on where it exists; `idle` is a heuristic
|
||||||
@@ -901,6 +905,25 @@ 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
|
keeps the server from storing what it would serve or an agent could run, and
|
||||||
html and svg are the ones deliberately kept out.
|
html and svg are the ones deliberately kept out.
|
||||||
|
|
||||||
|
## Deleting a file (`DELETE /api/v1/sessions/:id/files?path=`)
|
||||||
|
|
||||||
|
The Files panel's delete button. `path` is what the listing gave the row: relative to the
|
||||||
|
session's `workingDir`, or absolute inside the session's outside worktree (see
|
||||||
|
[Live worktree](#live-worktree-liveworktree-outsideworktree)). It removes that one regular file
|
||||||
|
and answers `{ path }` as sent. It never deletes a folder and never follows a symbolic link:
|
||||||
|
the file routes resolve a link to its target, so deleting through one would remove the file it
|
||||||
|
points to.
|
||||||
|
|
||||||
|
| Status | When |
|
||||||
|
| ------ | ---- |
|
||||||
|
| `200` | The file is gone. |
|
||||||
|
| `400` | A remote (SSH) session or an adopted session (both refused before any path is looked at), a missing or repeated `path`, a symbolic link, or anything that is not a regular file. |
|
||||||
|
| `403` | A sensitive file (`.env`, credentials and the like), a blocked attachment tree, or anything under `.git`. |
|
||||||
|
| `404` | No such file, a path outside the workspace (`../`, an absolute path elsewhere, a link leading out), a dangling link, or a session the caller does not own in multi-user mode. |
|
||||||
|
| `409` | The file was replaced between the checks and the delete; reload the listing. |
|
||||||
|
| `422` | The workspace's mount did not answer. |
|
||||||
|
|
||||||
|
## New folder in the path picker
|
||||||
## New folder in the path picker
|
## New folder in the path picker
|
||||||
|
|
||||||
`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one
|
`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one
|
||||||
@@ -1021,8 +1044,8 @@ of `liveWorktree` and `outsideWorktree`:
|
|||||||
Like `liveWorktree`, it is never restored after a restart.
|
Like `liveWorktree`, it is never restored after a restart.
|
||||||
|
|
||||||
Such a worktree is a second root for the session's file routes. `file-content` (read and edit),
|
Such a worktree is a second root for the session's file routes. `file-content` (read and edit),
|
||||||
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download` and
|
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download`,
|
||||||
`POST /api/v1/sessions/:id/html-view` accept an ABSOLUTE path inside it, while a relative path
|
`POST /api/v1/sessions/:id/html-view` and `DELETE /api/v1/sessions/:id/files` accept an ABSOLUTE path inside it, while a relative path
|
||||||
still resolves against `workingDir` only. A file there that the sensitive-path blocklist names
|
still resolves against `workingDir` only. A file there that the sensitive-path blocklist names
|
||||||
(`.env`, `.aws/credentials`, `users.json` and the like) is refused on every one of those routes.
|
(`.env`, `.aws/credentials`, `users.json` and the like) is refused on every one of those routes.
|
||||||
Codeman reports the worktree only when the session's own repository lists it, and only when `workingDir` is the top of that repository or of a linked worktree, so a session started in a subfolder of its repository reports none. It never reports:
|
Codeman reports the worktree only when the session's own repository lists it, and only when `workingDir` is the top of that repository or of a linked worktree, so a session started in a subfolder of its repository reports none. It never reports:
|
||||||
@@ -1129,6 +1152,14 @@ that sends the report, decided when the pane starts: `injected` (it does), `coll
|
|||||||
`.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or
|
`.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or
|
||||||
`remote` (remote and Docker sessions). Absent when unknown.
|
`remote` (remote and Docker sessions). Absent when unknown.
|
||||||
|
|
||||||
|
`contextWindowSize` is the model's context window in tokens from the same report (200000,
|
||||||
|
or 1000000 with extended context), absent until a report carries it and dropped, like
|
||||||
|
`promptCache`, on a post-`/clear` conversation switch. It is the upper bound for
|
||||||
|
`autoCompactThreshold` and `autoClearThreshold` (1000000 while it is absent): a higher value
|
||||||
|
is stored as the window, and Session Options stops its two inputs there. The thresholds
|
||||||
|
themselves are compared with the report's `context_window.total_input_tokens`, cache reads
|
||||||
|
included, and with the session's token counter for a launch without the exporter.
|
||||||
|
|
||||||
## Placing a new session in a tab group (`tabGroupId`)
|
## Placing a new session in a tab group (`tabGroupId`)
|
||||||
|
|
||||||
`POST /api/v1/sessions` and `POST /api/v1/quick-start` accept an optional
|
`POST /api/v1/sessions` and `POST /api/v1/quick-start` accept an optional
|
||||||
@@ -1240,8 +1271,9 @@ the tab to be tried again: Codeman never starts a local process in its place.
|
|||||||
|
|
||||||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||||||
AskUserQuestion questions, idle prompts). Answerable items come from Claude-mode
|
AskUserQuestion questions, idle prompts). Answerable items come from Claude-mode
|
||||||
sessions only; DeepSeek Harness (its status bridge) and Codex (dialogs read off its
|
sessions only; DeepSeek Harness (its status bridge), Codex (dialogs read off its
|
||||||
screen) open **alert-only** items with `answerInTerminal: true`. Items are
|
screen) and omp (its tool approval events, through the hook module Codeman installs)
|
||||||
|
open **alert-only** items with `answerInTerminal: true`. Items are
|
||||||
in-memory (a server restart drops them; the next prompt re-fires the hook, and a
|
in-memory (a server restart drops them; the next prompt re-fires the hook, and a
|
||||||
codex dialog still on screen is read again after the pane re-attaches).
|
codex dialog still on screen is read again after the pane re-attaches).
|
||||||
Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
|
Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
|
||||||
@@ -1595,12 +1627,12 @@ A local case whose folder is the top of a git repository can keep **worktrees**
|
|||||||
|
|
||||||
## Settings
|
## 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. 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 `{}`.
|
`~/.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`, `lastUsedCase`, `lastPickedCase` and `caseSettings`. 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`, `lastUsedCase`, `lastPickedCase` and `caseSettings`). **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 |
|
| 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`. |
|
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings`. |
|
||||||
| `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). |
|
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `caseSettings` (per-case run options, `{ [case]: { agentTeams, opusContext1m } }`) is merged per case inside the serialised writer, so devices saving different cases never overwrite each other; it is ignored while `syncCaseState` is not `true` and answers `400` past 500 cases. `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/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). |
|
| `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/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. |
|
||||||
@@ -1609,7 +1641,7 @@ A local case whose folder is the top of a git repository can keep **worktrees**
|
|||||||
|
|
||||||
A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key).
|
A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key).
|
||||||
|
|
||||||
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder` and `lastUsedCase` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
|
`POST /api/cases/:name/rename` (body `{ newName, renameFolder?, stopSessions? }`, strict) renames a case. It is not one of the admin-only writes above: in multi-user mode a non-admin renames their own cases, and only the `settings.json` part is skipped for them. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the answer is `500` and says it is now at the new path). A failed write is `500`. A linked case's entry is checked again where it is written, after any folder move, and a refusal there puts everything back the same way: a new name linked since the first check is `409 ALREADY_EXISTS` with `renameCode: "EXISTS"`, an old entry unlinked or changed since then is `409 CONFLICT` with `renameCode: "BUSY"`, and a `linked-cases.json` that stopped being readable or a JSON object is `409 CONFLICT` with no `renameCode` (one already damaged when the rename starts reads as empty, so a linked name answers `404`). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` (in multi-user mode, by an admin's rename only), `linked-cases.json` and Claude Code's `projects/<path>` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode: for a non-admin the name is looked up in their own case space only (`404` when they have no case of that name).
|
||||||
|
|
||||||
### System operations
|
### System operations
|
||||||
|
|
||||||
@@ -1637,6 +1669,23 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
|
|||||||
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
|
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
|
||||||
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
|
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
|
||||||
|
|
||||||
|
### Per-CLI availability probes
|
||||||
|
|
||||||
|
Each agent CLI has a read-only probe, `GET /api/<mode>/status`, which answers
|
||||||
|
`{ available, path }` for the binary the server would launch (no session needed, not gated).
|
||||||
|
`pi`, `grok`, `omp`, `copilot` and `jcode` add `version`, since their binary names are short or shared
|
||||||
|
with unrelated programs and the resolver rejects a candidate whose `--version` has the wrong
|
||||||
|
shape; `deepseek` answers a wider shape (`runnable`, `profiles`, ...). `GET /api/jcode/status`
|
||||||
|
is answered by the generic registry resolver, the same one a jcode spawn uses, so it applies
|
||||||
|
the entry's `jcode v<semver>` version anchor.
|
||||||
|
|
||||||
|
A jcode session takes `jcodeConfig: { model?, resumeSessionId? }` on `POST /api/sessions`
|
||||||
|
and `POST /api/quick-start` (`--model`, `--resume`; the resume value is a jcode session id or
|
||||||
|
short name and must start with a letter or digit). jcode takes no `envOverrides` prefix, and
|
||||||
|
`GET /api/sessions/:id/last-response` answers an empty `text` (and `messages: []` with
|
||||||
|
`?context=full`) for it: read its screen with `GET /api/sessions/:id/terminal?tail=` instead.
|
||||||
|
See `docs/jcode-integration.md`.
|
||||||
|
|
||||||
## MCP server sync
|
## MCP server sync
|
||||||
|
|
||||||
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
|
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
|
||||||
@@ -1716,7 +1765,7 @@ Only replies from `allowedUserIds`, in `guildId`, inside a thread the bridge cre
|
|||||||
|
|
||||||
## Local model
|
## Local model
|
||||||
|
|
||||||
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, and where an install of one stands. Five verbs install a model, pause and continue the install, stop and start the model runtime, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
|
`GET /api/local-model` looks at the machine the server runs on and says which local coding model fits it, with which agent CLI, where an install of one stands and which models are on the machine. Eight verbs install a model (a first one, or another one beside it), switch to a model that is already there, pause and continue a job, stop and start the model runtime, delete one model, install one more agent CLI beside an installed model, and remove everything again. It covers Apple silicon macOS only; anywhere else the GET is still a `200`, with verdict `none`, and nothing can be installed. How it works: [local-models.md](local-models.md).
|
||||||
|
|
||||||
**Experimental.** These routes and their payloads are labeled experimental: the fields below, the states, the error codes and the sentences may change or go in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on their shape yet.
|
**Experimental.** These routes and their payloads are labeled experimental: the fields below, the states, the error codes and the sentences may change or go in a MINOR or PATCH release (see [versioning-policy.md](versioning-policy.md), "What SemVer does NOT cover", item 3). Do not build on their shape yet.
|
||||||
|
|
||||||
@@ -1725,11 +1774,14 @@ Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before a
|
|||||||
| Method | Path | Body | Notes |
|
| Method | Path | Body | Notes |
|
||||||
| -------- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| -------- | -------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. |
|
| `GET` | `/api/local-model` | none | `200` with `{ hardware, recommendation, catalogUpdated, install }`. `500 INTERNAL_ERROR` only when the recommendation itself fails, never for a failed probe. |
|
||||||
| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed`, `size-changed`. |
|
| `POST` | `/api/local-model/install` | `{ modelId, cli, context, acknowledgedBytes }` | Starts the one install job and answers at once; the job runs on in the server. With a model installed it adds another one: the earlier model stays on disk, and the new one is the model in use when the job ends. `400 INVALID_INPUT` when the three choices are not a row the server offers right now. `409`: `job-running`, `already-installed` (this model at this context is the one in use), `size-changed`, `sessions-open`. |
|
||||||
|
| `POST` | `/api/local-model/use` | `{ modelId }` | Switches to a model of `install.library` whose `actions` list `use`. A job of the two steps `verify` and `connect`; nothing is downloaded. `400 INVALID_INPUT` when the model is not a checked model of the library, or no coding agent for it is installed. `409`: `job-running`, `wrong-state` (nothing is installed), `already-installed`, `sessions-open`. |
|
||||||
| `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. |
|
| `POST` | `/api/local-model/cancel` | none | Interrupts the step in flight; the job is `paused` at that step. `409 wrong-state` when no job is running. |
|
||||||
| `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`, `sessions-open` (`newPort` while live sessions use the model). |
|
| `POST` | `/api/local-model/resume` | `{ newPort? }` (may be absent) | Continues a `paused` or `failed` job at the same step. `newPort: true` chooses another port first. `409`: `job-running`, `wrong-state`, `sessions-open` (`newPort` while live sessions use the model). |
|
||||||
| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). |
|
| `POST` | `/api/local-model/runtime` | `{ action: "start" \| "stop", newPort? }` | `stop` stops the runtime and keeps it from starting at the next boot; `start` undoes that. `newPort: true` with `start` chooses another port and moves the saved endpoint to it. `409`: `job-running`, `wrong-state` (nothing is installed), `sessions-open` (`stop` or `newPort` while live sessions use the model: their agents keep calling the address they were started with). |
|
||||||
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the model only with `removeModel: true`. `409`: `job-running`, `sessions-open`. |
|
| `DELETE` | `/api/local-model` | `{ removeModel? }` (may be absent) | Stops the runtime, removes the saved endpoint, restores the settings flag when this feature switched it on and no other endpoint is saved, deletes the runtime; the downloaded models (all of them) only with `removeModel: true`. With nothing installed it is what deletes the models an earlier Remove kept. `409`: `job-running`, `sessions-open`. |
|
||||||
|
| `DELETE` | `/api/local-model/models` | `{ modelId }` | Deletes ONE model from the store, through the runtime. `modelId` is a model name (`name:tag`) of `install.library` whose `actions` list `delete`, never a path. `400 INVALID_INPUT` for a name that is not on this machine. `409`: `job-running`, `wrong-state` (nothing is installed), `model-active` (the model of the installation or of the current job), `runtime-not-running`. `500 OPERATION_FAILED` when the runtime did not delete it. |
|
||||||
|
| `POST` | `/api/local-model/agent` | `{ cli }` | Installs one more agent CLI beside an installed model and answers at once; the installer runs on in the server and `install.harnesses` says how it stands. `cli` is a row of `install.harnesses` whose `actions` list `install`; the server runs that row's `installCommand`, the release's own stock command, never text from the request. It is not a job: `install.state` stays `installed` whatever the installer does, sessions are not refused, and no live session is looked at. An agent that can already be started answers `200` with the view as it is. `400 INVALID_INPUT` for a CLI that is not a row of `install.harnesses`, or one that one command does not make startable (`setup-needed`). `409`: `job-running` (a job, or another agent install, is running), `wrong-state` (nothing is installed). |
|
||||||
|
|
||||||
Every verb answers `{ success: true, data: <install> }` with the `install` object (below) as it is after the verb. A refusal the feature names is a `409` whose envelope also carries the conflict:
|
Every verb answers `{ success: true, data: <install> }` with the `install` object (below) as it is after the verb. A refusal the feature names is a `409` whose envelope also carries the conflict:
|
||||||
|
|
||||||
@@ -1747,9 +1799,9 @@ Every verb answers `{ success: true, data: <install> }` with the `install` objec
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`data.reason` is `job-running`, `already-installed` (remove the installed model first), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model) or `wrong-state`. A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix.
|
`data.reason` is `job-running`, `already-installed` (the model is the one in use already), `size-changed` (with `expectedBytes` and `expectedGb`), `sessions-open` (with `sessions`, the names of the live sessions that use the model), `wrong-state`, `model-active` (a delete of the model in use: switch first, or remove everything) or `runtime-not-running` (a delete while the runtime is down: start it first). A `409 CONFLICT` without `data` means a config file that must not be overwritten (`settings.json` or `custom-model-hosts.json` does not parse); `error` names the fix.
|
||||||
|
|
||||||
`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the install names, and the `507 OPERATION_FAILED` of a first install on a full disk, carries its sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language.
|
`POST /api/local-model/install` also answers `400` when a saved model endpoint already uses the id `local-ollama` and was not created by this feature: rename or delete that endpoint first. The check is made before anything is downloaded. Every `400` the feature names, the `507 OPERATION_FAILED` of a first install on a full disk and the `500 OPERATION_FAILED` of a delete the runtime refused carry their sentence as `data.message` (`{ text, vars }`, like a `409`), so a client can print it in its own language.
|
||||||
|
|
||||||
**Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw.
|
**Consent is bound to the size shown.** `acknowledgedBytes` must be the `installBytes` of the chosen row: the model download plus the runtime download while the runtime is not unpacked yet. The server compares it with its own total before anything starts and answers `409 size-changed` when they differ, so a click confirms exactly the size the person saw.
|
||||||
|
|
||||||
@@ -1766,7 +1818,7 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
|
|||||||
- `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may be slow, have no speed figure or have no room on the disk); `notes` then says so.
|
- `verdict`: `ok`, `limited` or `none`. `limited` is a statement about the Mac: no model with 12 billion parameters or more fits its memory, so what it can run handles small edits and questions and does not replace a cloud model. It is judged on every model that fits, before the free disk space is looked at and whatever the pick is. With `ok` the pick can still be a small model (a larger one may be slow, have no speed figure or have no room on the disk); `notes` then says so.
|
||||||
- `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot).
|
- `reason`, present only with `none`: `not-macos`, `intel`, `memory`, `disk`, `macos` (too old) or `no-harness` (no agent CLI that can be connected to a local model is installed or installable). `memory` has three sentences: nothing fits at all (slot `memory`, the size the Mac is sold as), a model fits but only below the context length an agent CLI needs (slots `budget` and `need`, so a Mac that misses by a little reads as that), and the memory size could not be read (no slot).
|
||||||
- `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one.
|
- `headline` and `notes[]`: `{ text, vars? }`. `text` is an English sentence that may hold `{placeholder}` slots, and `vars` carries the slot values as text. Notes, in order: the pick is a small model although larger ones fit (verdict `ok` only); a model that fits the memory was left out because its download would not leave 10 GiB free on the disk (slots `model` and `missing`: the best-ranked such model and the GB to free up); nobody tested the pick on a Mac like this one.
|
||||||
- `pick` (absent with `none`) and `alternatives[]` (at most four, best first; empty with `none`), each one model at one context length with one agent CLI. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
|
- `pick` (absent with `none`) and `alternatives[]` (every other model of the catalogue that fits, best first; empty with `none`), each one model at one context length with one agent CLI. An install is accepted only for a row of this list. The order: a pair tested on a Mac like this one first, then speed (`ok` before `unknown` before `slow`), then the model's rank, the agent CLI's rank and the larger context. The memory fit decides whether a row is listed and what it says (`fit`, `tag`), it does not sort, so a model is offered at the largest context it fits at. A model whose download would leave less than 10 GiB free on the disk is not listed (an unknown free disk space excludes nothing):
|
||||||
- `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens);
|
- `modelId` (`name:tag`), `modelLabel`, `license`, `paramsB` (parameters in billions), `context` (tokens);
|
||||||
- `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`;
|
- `cli`, `cliLabel`, `cliInstalled` (`false`: the CLI is not on this machine yet but can be installed without administrator rights), `slowFirstTurn?`;
|
||||||
- `needBytes` and `needGb` (memory the model needs at that context, at its PEAK: the weights, what stays held per token of context, what the runner holds beside both, and what it takes for a moment while a long prompt is processed), `needSource` (`measured`: the figures were measured for this model on a Mac; `estimated`: they are another model's), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals);
|
- `needBytes` and `needGb` (memory the model needs at that context, at its PEAK: the weights, what stays held per token of context, what the runner holds beside both, and what it takes for a moment while a long prompt is processed), `needSource` (`measured`: the figures were measured for this model on a Mac; `estimated`: they are another model's), `downloadBytes` and `downloadGb`, `budgetShare` (need divided by `budgetBytes`, rounded to three decimals);
|
||||||
@@ -1775,22 +1827,26 @@ A probe that fails is an answer, not an error: a server that is not on macOS, or
|
|||||||
- `tag?`: `{ kind, label }`, the one tag of the row. `kind` is `tested`, `best`, `tight`, `slow` or `small`;
|
- `tag?`: `{ kind, label }`, the one tag of the row. `kind` is `tested`, `best`, `tight`, `slow` or `small`;
|
||||||
- `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`;
|
- `fit`: `good` (up to 0.85 of the budget) or `tight` (up to 0.98), with its label `fitLabel`;
|
||||||
- `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed;
|
- `speed`: `ok`, `slow` (under 10 tokens per second) or `unknown`, with `speedLabel` (absent for `ok`) and `tokensPerSecond` (rounded to one decimal, absent for `unknown`; `speed` is decided on the unrounded figure, so `10` can be `slow`). `unknown` means the chip is in no row of the catalogue's chip table, or the catalogue has no speed figure for the model: a speed is never guessed;
|
||||||
- `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run.
|
- `tested`: we ran this model with this CLI on a Mac like this one (same chip, same memory size). Its `tokensPerSecond` is then the speed measured in that run;
|
||||||
|
- `library?`: the model at this context is a checked model on this machine already (see `install.library`). `active`: it is the model in use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded. Absent otherwise.
|
||||||
- `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `estimated` (computed from the installed memory: all there is before the runtime ran), `wired-limit` (taken from `iogpu.wired_limit_mb`) or `measured` (the device total the runtime itself printed when it started; the GET uses it from the first install on).
|
- `budgetBytes` and `budgetGb`: the memory a model may use on this Mac (`0` for `not-macos` and `intel`, where none is computed). `budgetSource` is `estimated` (computed from the installed memory: all there is before the runtime ran), `wired-limit` (taken from `iogpu.wired_limit_mb`) or `measured` (the device total the runtime itself printed when it started; the GET uses it from the first install on).
|
||||||
- `catalogUpdated`: ISO date on which the catalogue's rows were last checked.
|
- `catalogUpdated`: ISO date on which the catalogue's rows were last checked.
|
||||||
- `install`: where the install stands and what can be done about it.
|
- `install`: where the install stands and what can be done about it.
|
||||||
- `state`: `idle` (nothing installed, no job), `installing`, `paused` (Cancel or a server restart stopped the job; it never continues without a click), `failed` or `installed`.
|
- `state`: `idle` (nothing installed, no job), `installing`, `paused` (Cancel or a server restart stopped the job; it never continues without a click), `failed` or `installed`.
|
||||||
- `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed.
|
- `step` and `steps[]` (present with `installing`, `paused` and `failed`): the step the job is in or stopped in, and the steps of this job in order, out of `runtime`, `model`, `verify`, `agent`, `connect`. `agent` is absent from a job whose agent CLI was already installed. A switch (`POST .../use`) is the two steps `verify` and `connect`.
|
||||||
- `progress` (while a download runs, and as it stood on a paused or failed job): `completedBytes`, `totalBytes`, `completedGb`, `totalGb`, and while it moves `bytesPerSecond`, `mbPerSecond` (whole decimal megabytes per second as text) and `secondsLeft`. During the `model` step both byte figures come from the download's own lines, not from the catalogue.
|
- `progress` (while a download runs, and as it stood on a paused or failed job): `completedBytes`, `totalBytes`, `completedGb`, `totalGb`, and while it moves `bytesPerSecond`, `mbPerSecond` (whole decimal megabytes per second as text) and `secondsLeft`. During the `model` step both byte figures come from the download's own lines, not from the catalogue.
|
||||||
- `error` (with `failed`; with `installed` only when something blocks a session): `{ code, message: { text, vars? }, next, nextModel?, detail? }`. `next` is the ONE action that helps: `retry` (`POST .../resume`), `other-port` (`POST .../resume` or `.../runtime` with `newPort: true`), `next-model` (choose again; `nextModel` is `{ modelId, modelLabel }` when the server knows one), `start` (`POST .../runtime`) or `remove` (`DELETE`). `detail` is raw text for the curious (an installer's last lines), not translated.
|
- `error` (with `failed`; with `installed` only when something blocks a session): `{ code, message: { text, vars? }, next, nextModel?, detail? }`. `next` is the ONE action that helps: `retry` (`POST .../resume`), `other-port` (`POST .../resume` or `.../runtime` with `newPort: true`), `next-model` (choose again; `nextModel` is `{ modelId, modelLabel }` when the server knows one), `start` (`POST .../runtime`) or `remove` (`DELETE`). `detail` is raw text for the curious (an installer's last lines), not translated.
|
||||||
- `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. `port`: the runtime's loopback port, once chosen.
|
- `model` (`{ id, label }`), `cli`, `cliLabel`, `context`: the choice of the job or of the installation. With `installed`, `cli` is the DEFAULT agent CLI, the one a client that names none starts: the agent of the installation while it can be started, else the first row of `harnesses` that can. `port`: the runtime's loopback port, once chosen.
|
||||||
- `running`: the runtime Codeman started is alive right now. A runtime the person stopped is `running: false` with no `error`.
|
- `running`: the runtime Codeman started is alive right now. A runtime the person stopped is `running: false` with no `error`.
|
||||||
- `loaded` (with `installed`): what the runtime reported when the job checked the loaded model: `context`, `memoryBytes`, `memoryGb`, `onGpu`.
|
- `loaded` (with `installed`): what the runtime reported when the job checked the loaded model: `context`, `memoryBytes`, `memoryGb`, `onGpu`.
|
||||||
- `endpointId` (with `installed`): the saved model endpoint a session is started on, with `cli`.
|
- `endpointId` (with `installed`): the saved model endpoint a session is started on, with `cli` or with any row of `harnesses` that offers `start-session`.
|
||||||
- `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`.
|
- `developerTools`: `present`, `missing` (the Mac has no Xcode or Command Line Tools, so macOS will offer to install them the first time an agent runs `git` or `python3`) or `unknown`.
|
||||||
- `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked.
|
- `runtime`: `{ version, installed, downloadBytes, downloadGb, unpackedGb }`. `downloadBytes` is `0` once the runtime is unpacked.
|
||||||
- `folder`: the absolute folder everything of this feature is kept in.
|
- `folder`: the absolute folder everything of this feature is kept in.
|
||||||
- `actions[]`: exactly what the server accepts right now, out of `install`, `cancel`, `resume`, `start`, `stop`, `remove` and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs and nothing blocks a session).
|
- `library[]`: the models on this machine, the model in use first. Empty with `idle` (no runtime is there to ask). Each row: `id` (the name the runtime lists, `name:tag`), `label`, `context?` (the context it was checked with), `sizeBytes?` and `sizeGb?` (its size on disk as the runtime reports it, else as recorded when it was checked), `active` (it is the model in use), `verified` (Codeman checked it), `status` and `actions[]`. `status` is `ready`, `changed` (checked, and the runtime now reports another digest under the name), `missing` (checked, and the runtime no longer lists it) or `unverified` (the runtime lists it and Codeman never checked it: a model an earlier Remove kept, the model of a job that has not passed its check, or one another program pulled through the runtime's port). `actions` is what the server accepts for THAT row right now, out of `use` and `delete`; `delete` needs the running runtime, and a name that is not a plain `name:tag` is listed with no action.
|
||||||
|
- `store?`: `{ bytes, gb }`, what the model store takes on disk (the files of its blob folder, so shared layers count once and an interrupted download is included). Present in every state once it was measured; cached for about thirty seconds.
|
||||||
|
- `harnesses[]`: the agent CLIs a session on the model in use can be started with, the default one first, then by the catalogue's rank. The agent is chosen per session: a session is started with `mode` set to a row's `cli` and `customModel: { endpointId, modelId }`. Empty in every state but `installed`. Each row: `cli`, `cliLabel`, `default` (true on the row `install.cli` names), `status`, `installCommand?` (with `installable` and `installing`: the literal command `POST .../agent` runs), `slowFirstTurn?` (the first answer takes minutes), `readyMark?` (a literal without white space that the agent's composer draws once it takes input: what a script waits for on `GET /api/sessions/:id/wait-output` before it sends the first prompt; absent for an agent with no measured mark. A mark says the composer is there, not that nothing else is: DeepSeek Harness with dsh-TUI 0.19 opens a setup guide whose cursor is its `❯` too until a person has finished it once, so a script reads the pane after the mark), `error?` (`{ message, detail? }`: the last install of this agent through `POST .../agent` failed; kept in memory only) and `actions[]` (`start-session`, `install`). `status` is `ready` (it can be started, the answer the Run menu gets), `installable` (not on this machine, and `POST .../agent` installs it), `installing` (that install is running; the model stays as it is) or `setup-needed` (not startable, and one command does not make it so: DeepSeek Harness without a terminal profile, or a command that needs an administrator; set it up from the Run menu or a terminal).
|
||||||
|
- `actions[]`: exactly what the server accepts right now, out of `install` (with `installed`: add another model), `cancel`, `resume`, `start`, `stop`, `remove` (with `idle` only while `store` holds something), `use` and `delete-model` (only while a row of `library` offers them), `install-agent` (only while a row of `harnesses` offers `install`) and `start-session` (not a route here: start a session on `endpointId`; offered only while the runtime runs, nothing blocks a session and, when `harnesses` is known, at least one agent CLI can be started).
|
||||||
|
|
||||||
Error codes (`install.error.code`), each with one next step:
|
Error codes (`install.error.code`), each with one next step:
|
||||||
|
|
||||||
@@ -1800,9 +1856,11 @@ Error codes (`install.error.code`), each with one next step:
|
|||||||
| `start` | `runtime-stopped` (the runtime crashed three times in a row and is no longer restarted) |
|
| `start` | `runtime-stopped` (the runtime crashed three times in a row and is no longer restarted) |
|
||||||
| `other-port` | `port-in-use` |
|
| `other-port` | `port-in-use` |
|
||||||
| `next-model` | `model-not-found`, `does-not-fit`, `no-tool-call` |
|
| `next-model` | `model-not-found`, `does-not-fit`, `no-tool-call` |
|
||||||
| `remove` | `model-changed` (the model's digest is no longer the one stored at install time; a session on it is refused) |
|
| `remove` | `model-changed` (the model's digest is no longer the one stored when it was checked; a session on it is refused, and so is a switch to it) |
|
||||||
|
|
||||||
Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, when the model's digest changed, or for a model other than the installed one.
|
Two codes are only ever the reason of a refused session start, never on `install.error`: `runtime-not-running` (the person stopped the runtime) and `job-unfinished` (a job that changes the model in use is running or stopped: nothing is started until it ended `installed` again).
|
||||||
|
|
||||||
|
Starting a session on the saved endpoint goes through the existing custom model launch (`POST /api/quick-start` with `customModel`, or `POST /api/sessions/:id/custom-model`). For the endpoint this feature manages both answer `OPERATION_FAILED` with one sentence while the runtime is not running, while a job that changes the model in use is running or stopped, when the model's digest changed, or for a model other than the one in use.
|
||||||
|
|
||||||
Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`.
|
Every computed size a person reads travels twice: as bytes, and as decimal GB text without the unit (`"needGb": "29.6"`). Show the text and add the unit; do not divide the bytes yourself, so one request never shows two different roundings. The installed memory is the exception named above: show `memClassGb`.
|
||||||
|
|
||||||
@@ -1823,7 +1881,8 @@ same speech-to-text service the CLI's own `/voice` mode uses. Gated on the synce
|
|||||||
little-endian PCM, 16 kHz mono (<= 64 KB per frame), plus JSON control frames
|
little-endian PCM, 16 kHz mono (<= 64 KB per frame), plus JSON control frames
|
||||||
`{"t":"finalize"}` (ask for the final transcript) and `{"t":"stop"}`. Server
|
`{"t":"finalize"}` (ask for the final transcript) and `{"t":"stop"}`. Server
|
||||||
sends `{"t":"ready"}`, `{"t":"transcript","text","final"}` (each frame is the
|
sends `{"t":"ready"}`, `{"t":"transcript","text","final"}` (each frame is the
|
||||||
WHOLE running transcript, not a delta), `{"t":"error","message"}` and
|
current utterance so far, not a delta, and a `final: true` frame ends that
|
||||||
|
utterance), `{"t":"error","message"}` and
|
||||||
`{"t":"closed"}`. Close codes: `4003` disallowed Host/Origin, `4004`
|
`{"t":"closed"}`. Close codes: `4003` disallowed Host/Origin, `4004`
|
||||||
unavailable (reason in the close reason), `4008` too many concurrent streams.
|
unavailable (reason in the close reason), `4008` too many concurrent streams.
|
||||||
Streams are capped in count and length (`src/config/voice.ts`).
|
Streams are capped in count and length (`src/config/voice.ts`).
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
|
|||||||
|
|
||||||
## Session launch modes
|
## Session launch modes
|
||||||
|
|
||||||
**Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other.
|
**Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP and jcode export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other.
|
||||||
|
|
||||||
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
|
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
|
||||||
|
|
||||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--no-daemon` behind the `noDaemonFlag` gate at 0.162.0, measured against codex's own version, local launches only; `--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config sandbox_workspace_write.network_access=true` from `networkAccess` (the **Allow network access** app setting `codexNetworkAccess`, a launch default and a privileged param; ⚠️ with it on, a command in codex's sandbox can call Codeman's API and start a session outside that sandbox, password or not), `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fuLine truncated
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot, jcode)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--no-daemon` behind the `noDaemonFlag` gate at 0.162.0, measured against codex's own version, local launches only; `--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config sandbox_workspace_write.network_access=true` from `networkAccess` (the **Allow network access** app setting `codexNetworkAccess`, a launch default and a privileged param; ⚠️ with it on, a command in codex's sandbox can call Codeman's API and start a session outside that sandbox, password or not), `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `run()` → the shared `_runCliMode(mode)` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUTLine truncated
|
||||||
|
|
||||||
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
|
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
|
||||||
|
|
||||||
@@ -65,6 +65,8 @@ Model is NOT a session field: it is a composition entry in the profile's config
|
|||||||
|
|
||||||
**OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume <id>`/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/<semver>`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`.
|
**OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume <id>`/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/<semver>`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`.
|
||||||
|
|
||||||
|
**jcode specifics** (`docs/jcode-integration.md`): every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`, `setsid`, but still the first client's child by parent pid, so ⚠️ `capabilities.sharedDaemon` makes `killSession` spare descendants outside the pane's process group via `descendantsInPaneGroup()` in `proc-tree.ts`; without it, closing the tab that started the daemon cut every other jcode session off, measured), and the agent's tools run in the DAEMON with the daemon's environment (measured: a second client's env var read back empty from its bash tool). So ⚠️ the agent skill is NOT injected (`agentSkillInjection: false`: the tools would carry the `CODEMAN_*` identity of whichever session started the daemon), and ⚠️ `JCODE_*` is NOT an `envOverrides` prefix (`allowedPrefixes: []`: that namespace is daemon-read and includes `JCODE_HOOK_*`, `JCODE_SPAWN_HOOK`, `JCODE_*_COMMAND` and `JCODE_*_API_BASE`, so a per-session value does nothing or reaches every jcode session). No approval prompts and no bypass flag (pi-shaped: `privilegedParams: []`, no `blockedLine`). ⚠️ `last-response` answers an honest empty for it: the generic rule `capabilities.external && transcript === 'none'` placed AFTER the pane-segmenter branch (it matches jcode alone today) keeps it off the segmenter, which served jcode's welcome banner as the answer, and off the Claude transcript scan, which could adopt a Claude conversation from the same folder. Binary resolution is the generic registry resolver (`/api/jcode/status` included); there is no per-CLI resolver module.
|
||||||
|
|
||||||
**Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) after an UNPREDICTED wire edit (backspace into echoed text, any 'clear'-classified input, an IME/plain-paste 'text' commit, or every bypass send incl. `_handleCjkInput`) the addon holds new predictions until the next PARSED write: the displayed cursor is stale for one RTT and anchoring on it paints ghosts one cell off; (6) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incLine truncated
|
**Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) after an UNPREDICTED wire edit (backspace into echoed text, any 'clear'-classified input, an IME/plain-paste 'text' commit, or every bypass send incl. `_handleCjkInput`) the addon holds new predictions until the next PARSED write: the displayed cursor is stale for one RTT and anchoring on it paints ghosts one cell off; (6) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incLine truncated
|
||||||
|
|
||||||
Further detail: with the registry, a CLI that declares its own composer glyph and working line in `capabilities.workDetect` gets the same screen-probed idle confirmation claude gets (see the ❯ note in CLAUDE.md's idle-detection paragraph), and one that declares neither keeps the output-stabilization behaviour. The tmux-only rule now covers all EIGHT external modes (OMP included): none has a direct PTY fallback, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line.
|
Further detail: with the registry, a CLI that declares its own composer glyph and working line in `capabilities.workDetect` gets the same screen-probed idle confirmation claude gets (see the ❯ note in CLAUDE.md's idle-detection paragraph), and one that declares neither keeps the output-stabilization behaviour. The tmux-only rule now covers all EIGHT external modes (OMP included): none has a direct PTY fallback, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line.
|
||||||
@@ -147,7 +149,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
|
|||||||
|
|
||||||
### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR
|
### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR
|
||||||
|
|
||||||
**The env allowlist has two tiers, and exceptions go in the exact-key tier, never a widened prefix** (#255): `ALLOWED_ENV_PREFIXES` in `src/web/schemas.ts` carries the CLI-namespace prefixes, and `ALLOWED_ENV_KEYS` carries exact keys (currently only `CLAUDE_CONFIG_DIR`). `CLAUDE_CONFIG_DIR` relocates the Claude CLI's user config (credentials, settings, stats), which is how one machine runs sessions on separate Claude subscriptions: point a case's sessions at e.g. `~/.claude-clients/acme` via `envOverrides` and run `/login` there once against the client's account. The exact match matters: `CLAUDE_` as a prefix would open every future Claude CLI variable unreviewed, and near-misses (`CLAUDE_CONFIG_DIR_EXTRA`) stay rejected (`test/env-overrides-schema.test.ts`). No new security boundary is crossed: sessions already run as the server's OS account, and `applyEnvOverrides()` shellescapes values into socket-scoped `tmux setenv`. Two carry rules: **(1)** the key must survive `getEnvOverridesForPersist()` in `session.ts` (it is a path, not a secret; dropping it from state.json would silently move a rebuilt-after-reboot session back to the default account); **(2)** ⚠️ a relocated config dir writes transcripts outside `homedir()/.claude/projects`, which `subagent-watcher.ts`, `workflow-run-watcher.ts`, the response-viewer routes and Read My Mind capture all hardcode — those surfaces go blind for such a session. Documented workaround: symlink the transcripts back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`), keeping credentials separate while the watchers keep working.
|
**The env allowlist has two tiers, and exceptions go in the exact-key tier, never a widened prefix** (#255): `ALLOWED_ENV_PREFIXES` in `src/web/schemas.ts` carries the CLI-namespace prefixes, and `ALLOWED_ENV_KEYS` carries exact keys (currently only `CLAUDE_CONFIG_DIR`). `CLAUDE_CONFIG_DIR` relocates the Claude CLI's user config (credentials, settings, stats), which is how one machine runs sessions on separate Claude subscriptions: point a case's sessions at e.g. `~/.claude-clients/acme` via `envOverrides` and run `/login` there once against the client's account. The exact match matters: `CLAUDE_` as a prefix would open every future Claude CLI variable unreviewed, and near-misses (`CLAUDE_CONFIG_DIR_EXTRA`) stay rejected (`test/env-overrides-schema.test.ts`). No new security boundary is crossed: sessions already run as the server's OS account, and `applyEnvOverrides()` shellescapes values into socket-scoped `tmux setenv`. Two carry rules: **(1)** the key must survive `getEnvOverridesForPersist()` in `session.ts` (it is a path, not a secret; dropping it from state.json would silently move a rebuilt-after-reboot session back to the default account); **(2)** ⚠️ a relocated config dir writes transcripts outside `homedir()/.claude/projects`, which `subagent-watcher.ts`, `workflow-run-watcher.ts` and Read My Mind capture all hardcode: those surfaces go blind for such a session. Documented workaround: symlink the transcripts back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`), keeping credentials separate while the watchers keep working. The response viewer is no longer one of them (#701): `claudeConversationDirs()` in `session-routes.ts` gives `lastResponseFor` (the viewer, `codeman agent read`, the Discord bridge's last message) and the rename's `/resume` title the folder the session's claude really writes to, `<config dir>/projects` and `<config dir>/history.jsonl`, resolved by `claudeProjectsDir()` like the relaunch pin and the Edit history panel: the session's own override, then the server's `CLAUDE_CONFIG_DIR` (a pane inherits it through tmux), then `~/.claude`. ⚠️ Only a LOCAL session reads its override: in a Docker case or on a remote host the value names a folder over there. ⚠️ A relocated folder is a user-chosen path, so it is probed bounded first (#516), and anything but a directory reads as "no transcript": never fall back to `~/.claude`, where a file of the same name is another account's conversation. The default folder is not probed, so that path is unchanged. Tests: `test/routes/session-routes-claude-last-response.test.ts` (relocated folder block), `test/routes/session-name-routes.test.ts`.
|
||||||
|
|
||||||
⚠️ **As of the custom-model endpoint feature, `CLAUDE_CONFIG_DIR` is ALSO admin-only in multi-user mode**, which is a change to the above rather than a restatement of it. It joined claude's `privilegedEnvKeys` (`stock.ts`) alongside `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, because the rule that every traffic-redirecting var that feature can inject must be listed there is worth keeping literally true. `privilegedEnvKeys` has exactly one consumer, `ownerClampedEnvKeys()` in `src/session-env-clamp.ts`, which feeds the generic `envOverrides` clamp on `POST /api/sessions`, `POST /api/quick-start` and reboot-restore. So for a non-granted owner two things now follow: the key cannot be set through `envOverrides` at all, and an ALREADY-PERSISTED one is stripped on reboot-restore, which silently moves that session back to the default Claude account. That second consequence is the one to watch, since it turns a working per-client setup into a wrong-account one across a host reboot with no error anywhere. `session-env-clamp.ts`'s own fileoverview used to state the opposite invariant (that claude's privileged keys are the five `ANTHROPIC_*` names, so a persisted record cannot carry a clamped key) and was corrected when this landed; a claude clamp test now pins the behaviour next to the deepseek and omp ones.
|
⚠️ **As of the custom-model endpoint feature, `CLAUDE_CONFIG_DIR` is ALSO admin-only in multi-user mode**, which is a change to the above rather than a restatement of it. It joined claude's `privilegedEnvKeys` (`stock.ts`) alongside `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, because the rule that every traffic-redirecting var that feature can inject must be listed there is worth keeping literally true. `privilegedEnvKeys` has exactly one consumer, `ownerClampedEnvKeys()` in `src/session-env-clamp.ts`, which feeds the generic `envOverrides` clamp on `POST /api/sessions`, `POST /api/quick-start` and reboot-restore. So for a non-granted owner two things now follow: the key cannot be set through `envOverrides` at all, and an ALREADY-PERSISTED one is stripped on reboot-restore, which silently moves that session back to the default Claude account. That second consequence is the one to watch, since it turns a working per-client setup into a wrong-account one across a host reboot with no error anywhere. `session-env-clamp.ts`'s own fileoverview used to state the opposite invariant (that claude's privileged keys are the five `ANTHROPIC_*` names, so a persisted record cannot carry a clamped key) and was corrected when this landed; a claude clamp test now pins the behaviour next to the deepseek and omp ones.
|
||||||
|
|
||||||
@@ -165,7 +167,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
|
|||||||
|
|
||||||
**Signal availability is decided by MODE, not by `isExternalCliMode()`.** `stop` and `blocked` come from Claude Code hooks, so `hooksAvailableForMode()` is true only for `claude`: `shell` is not an external-CLI mode but installs no hooks either, so `until=stop` on a shell session is a guaranteed unresolvable wait dressed up as a timeout. The behavior split is deliberate and must survive refactors: an EXPLICIT request for an unavailable signal is a 400 naming the mode, while the DEFAULT set (`stop,idle,exit`) silently drops them and echoes the narrowed set back as `wait.until`, because omitting the parameter must never 400. Signal quality is not uniform either: `stop` is definitive (Claude Code says the turn is over), `idle` is inferred from output stabilization plus prompt detection and can flap mid-turn when a spinner pauses, which is why `stop` is the documented default to orchestrate on and `idle` is the fallback for sessions that emit no hooks. ⚠️ **`idle` being ACCEPTED for a mode does not mean it ever FIRES there.** `startShell()` emits exactly one `idle` on a 500ms readiness timer and nothing afterwards, so a shell session sits at `status:'idle'` no matter what its pane is doing; since send-and-wait and `fresh=1` both require a TRANSITION, both can only time out on a shell worker (measured: a default `wait` on `sleep 4` burned its full 25s). The ❯-prompt and spinner detection that drives the real `working`/`idle` cycle is Claude's output format, so hook-less modes synchronize with `wait-output` markers or `exit`, and the docs must say so rather than listing `idle` as "available" and letting the reader infer it is usable. ⚠️ **Signals are edge-triggered with no history**, and that is a real orchestration limit: a signal that fires while no waiter is registered is gone, unobservable by any later wait variant (`until=stop` after the turn ended just times out, `fresh` or not — measured, R2-A). Documented client patterns must therefore register the waiter before the event can fire (send-and-wait) or gather on latched `wait-output` markers with `from=buffer`; the skill's fan-out flow was rewritten accordingly, and "fire-and-forget N prompts, then gather signal-waits sequentially" must never be documented again. The durable fix, a server-side latched last-signal-per-turn, is deferred with Part 3 of `docs/agent-control-plan.md`. ⚠️ Relatedly, the route corrects liveness that `SessionStatus` cannot express: `currentSignalFor()` answers `exit` whenever `pid === null` (exited, detached, or created-and-never-started) **or the mux pane is dead**, because `Session` parks a DEAD PTY at `_status = 'idle'` and trusting the status would answer the default wait `{signal:'idle', immediate:true}` for a crashed worker while `until=exit` blocked forever on an event that already happened. ⚠️ **`pid` alone cannot carry liveness for a tmux-backed session**: that pid is the local `tmux attach` CLIENT, so a worker exiting inside its pane leaves `pane_dead=1` with the client alive and `pid` never goes null — the `pid === null` branch is unreachable in the normal configuration (unit tests exercise it because `MockSession` sets `pid` by hand; only a live instance showed the gap). Liveness is therefore probed at the mux layer: `workerIsDead()` consults `mux.isPaneDead(muxName)` with a ~750 ms per-pane cache, ONLY on blocking waits (measured: 0 tmux execs across 100 non-wait input POSTs — the browser hot path pays nothing), plus a refcounted 3 s `watchForDeadWorker` interval so a worker dying while a wait is parked resolves it in ~3 s instead of burning the timeout. The probe fails SAFE by construction ("cannot tell" is never "dead": non-mux sessions, a missing or throwing `isPaneDead`, all return false). On send-and-wait, a "successful" `send-keys` into a dead pane additionally overrides `delivered` to `false` and rolls the dedup seq back (`undoOnFailure`), because the bytes went nowhere and a retry against a restarted worker must not be refused as a duplicate. The cost is that a just-created session reads as `exit`, which the wire docs must spell out as "not started yet"; the fix lives at the route rather than in `signalForStatus()` because the registry deliberately holds no `Session` reference. ⚠️ Two `claude`-mode cases still lose hooks for reasons outside the registry: a Docker case cannot reach a loopback-bound Codeman without `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, and a remote-SSH case runs the agent on another host whose hooks may never reach this server. Bounds are env-overridable (`CODEMAN_WAIT_MAX_MS`, `CODEMAN_WAIT_DEFAULT_MS`, `CODEMAN_WAIT_MAX_PER_SESSION`, `CODEMAN_WAIT_MAX_PER_OWNER`, `CODEMAN_WAIT_MAX_TOTAL`, `CODEMAN_WAIT_BUFFER_SCAN_BYTES`) and each is clamped to a hard bound so a typo degrades to the default instead of disabling the protection; they are internal tuning knobs like the rest of `src/config/`, NOT part of the SemVer-covered env-var surface in `versioning-policy.md`. Tests: `tesLine truncated
|
**Signal availability is decided by MODE, not by `isExternalCliMode()`.** `stop` and `blocked` come from Claude Code hooks, so `hooksAvailableForMode()` is true only for `claude`: `shell` is not an external-CLI mode but installs no hooks either, so `until=stop` on a shell session is a guaranteed unresolvable wait dressed up as a timeout. The behavior split is deliberate and must survive refactors: an EXPLICIT request for an unavailable signal is a 400 naming the mode, while the DEFAULT set (`stop,idle,exit`) silently drops them and echoes the narrowed set back as `wait.until`, because omitting the parameter must never 400. Signal quality is not uniform either: `stop` is definitive (Claude Code says the turn is over), `idle` is inferred from output stabilization plus prompt detection and can flap mid-turn when a spinner pauses, which is why `stop` is the documented default to orchestrate on and `idle` is the fallback for sessions that emit no hooks. ⚠️ **`idle` being ACCEPTED for a mode does not mean it ever FIRES there.** `startShell()` emits exactly one `idle` on a 500ms readiness timer and nothing afterwards, so a shell session sits at `status:'idle'` no matter what its pane is doing; since send-and-wait and `fresh=1` both require a TRANSITION, both can only time out on a shell worker (measured: a default `wait` on `sleep 4` burned its full 25s). The ❯-prompt and spinner detection that drives the real `working`/`idle` cycle is Claude's output format, so hook-less modes synchronize with `wait-output` markers or `exit`, and the docs must say so rather than listing `idle` as "available" and letting the reader infer it is usable. ⚠️ **Signals are edge-triggered with no history**, and that is a real orchestration limit: a signal that fires while no waiter is registered is gone, unobservable by any later wait variant (`until=stop` after the turn ended just times out, `fresh` or not — measured, R2-A). Documented client patterns must therefore register the waiter before the event can fire (send-and-wait) or gather on latched `wait-output` markers with `from=buffer`; the skill's fan-out flow was rewritten accordingly, and "fire-and-forget N prompts, then gather signal-waits sequentially" must never be documented again. The durable fix, a server-side latched last-signal-per-turn, is deferred with Part 3 of `docs/agent-control-plan.md`. ⚠️ Relatedly, the route corrects liveness that `SessionStatus` cannot express: `currentSignalFor()` answers `exit` whenever `pid === null` (exited, detached, or created-and-never-started) **or the mux pane is dead**, because `Session` parks a DEAD PTY at `_status = 'idle'` and trusting the status would answer the default wait `{signal:'idle', immediate:true}` for a crashed worker while `until=exit` blocked forever on an event that already happened. ⚠️ **`pid` alone cannot carry liveness for a tmux-backed session**: that pid is the local `tmux attach` CLIENT, so a worker exiting inside its pane leaves `pane_dead=1` with the client alive and `pid` never goes null — the `pid === null` branch is unreachable in the normal configuration (unit tests exercise it because `MockSession` sets `pid` by hand; only a live instance showed the gap). Liveness is therefore probed at the mux layer: `workerIsDead()` consults `mux.isPaneDead(muxName)` with a ~750 ms per-pane cache, ONLY on blocking waits (measured: 0 tmux execs across 100 non-wait input POSTs — the browser hot path pays nothing), plus a refcounted 3 s `watchForDeadWorker` interval so a worker dying while a wait is parked resolves it in ~3 s instead of burning the timeout. The probe fails SAFE by construction ("cannot tell" is never "dead": non-mux sessions, a missing or throwing `isPaneDead`, all return false). On send-and-wait, a "successful" `send-keys` into a dead pane additionally overrides `delivered` to `false` and rolls the dedup seq back (`undoOnFailure`), because the bytes went nowhere and a retry against a restarted worker must not be refused as a duplicate. The cost is that a just-created session reads as `exit`, which the wire docs must spell out as "not started yet"; the fix lives at the route rather than in `signalForStatus()` because the registry deliberately holds no `Session` reference. ⚠️ Two `claude`-mode cases still lose hooks for reasons outside the registry: a Docker case cannot reach a loopback-bound Codeman without `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, and a remote-SSH case runs the agent on another host whose hooks may never reach this server. Bounds are env-overridable (`CODEMAN_WAIT_MAX_MS`, `CODEMAN_WAIT_DEFAULT_MS`, `CODEMAN_WAIT_MAX_PER_SESSION`, `CODEMAN_WAIT_MAX_PER_OWNER`, `CODEMAN_WAIT_MAX_TOTAL`, `CODEMAN_WAIT_BUFFER_SCAN_BYTES`) and each is clamped to a hard bound so a typo degrades to the default instead of disabling the protection; they are internal tuning knobs like the rest of `src/config/`, NOT part of the SemVer-covered env-var surface in `versioning-policy.md`. Tests: `tesLine truncated
|
||||||
|
|
||||||
Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex).
|
Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has two more sources**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), or have its own approval events forwarded by a hook module Codeman installs into a local workspace (`capabilities.approvalHookFile`, omp; `approvalHookAvailableForMode()` says no for an adopted, remote or Docker session, where nothing was installed), and `blockedSignalAvailable()` (hooks OR either field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex).
|
||||||
|
|
||||||
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off. Existing installs that stored off are switched on once at the first 1.50.0 start; turning it off afterwards sticks. Every App Settings Save up to 1.49.0 wrote `false` (the switch loaded an absent key as off and Save sends the whole form), so without that the default would never reach anyone who had ever saved. ⚠️ The migration (`migrateAgentSkillDefault()`, `web/agent-skill-default-migration.ts`) runs from `WebServer.start()` before the first settings read (and the 2 s `readSettings()` cache, which it drops after a write) and before listen, never in test mode, and writes only through `mutateSettingsFile()`; a `settings.json` that does not parse is left alone with one log line and no marker, so the next start after the fix tries again (and switches on an off saved between the fix and that start, since the migration has not run yet). It cannot tell a deliberate off from the old default (accepted, a "Behaviour change" in the release notes). ⚠️ It stores `true` rather than deleting the key: `loadAppSettingsFromServer()` overwrites only the synced keys the server HAS, so every device that saved on 1.49 would keep its own `false`, show the switch off and write `false` back on its next Save (`true` also wins in a browser still running a cached settings-ui.js); for a server with no key the page drops the device's copy too. ⚠️ Its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir via `dataPath()`, checked before anything is read and written only after the settings write landed (a crash between the two is harmless: the next start finds `true`). Never a settings key: `GET /api/settings` never returns it, so no Save can send it back (the `.strict()` `SettingsUpdateSchema` would 400) or drop it (which would re-arm the migration and switch a later off back on), and it is outside `CONFIG_BACKUP_FILES`, so once the migration has run, a restored settings.json, even a pre-1.50 one holding `false`, stays as restored. One data dir is one server, so in multi-user mode it runs once for the server. A device whose pages were all loaded before the upgrade still holds the old `false` in its localStorage blob until a page load refreshes it, and an App Settings Save from such a page writes that back (accepted; the release note says to reload). Tests: `test/agent-skill-default-migration.test.ts`, `test/agent-skill-setting-ui.test.ts`. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `<copy>/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `referenLine truncated
|
**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case <name>]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off. Existing installs that stored off are switched on once at the first 1.50.0 start; turning it off afterwards sticks. Every App Settings Save up to 1.49.0 wrote `false` (the switch loaded an absent key as off and Save sends the whole form), so without that the default would never reach anyone who had ever saved. ⚠️ The migration (`migrateAgentSkillDefault()`, `web/agent-skill-default-migration.ts`) runs from `WebServer.start()` before the first settings read (and the 2 s `readSettings()` cache, which it drops after a write) and before listen, never in test mode, and writes only through `mutateSettingsFile()`; a `settings.json` that does not parse is left alone with one log line and no marker, so the next start after the fix tries again (and switches on an off saved between the fix and that start, since the migration has not run yet). It cannot tell a deliberate off from the old default (accepted, a "Behaviour change" in the release notes). ⚠️ It stores `true` rather than deleting the key: `loadAppSettingsFromServer()` overwrites only the synced keys the server HAS, so every device that saved on 1.49 would keep its own `false`, show the switch off and write `false` back on its next Save (`true` also wins in a browser still running a cached settings-ui.js); for a server with no key the page drops the device's copy too. ⚠️ Its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir via `dataPath()`, checked before anything is read and written only after the settings write landed (a crash between the two is harmless: the next start finds `true`). Never a settings key: `GET /api/settings` never returns it, so no Save can send it back (the `.strict()` `SettingsUpdateSchema` would 400) or drop it (which would re-arm the migration and switch a later off back on), and it is outside `CONFIG_BACKUP_FILES`, so once the migration has run, a restored settings.json, even a pre-1.50 one holding `false`, stays as restored. One data dir is one server, so in multi-user mode it runs once for the server. A device whose pages were all loaded before the upgrade still holds the old `false` in its localStorage blob until a page load refreshes it, and an App Settings Save from such a page writes that back (accepted; the release note says to reload). Tests: `test/agent-skill-default-migration.test.ts`, `test/agent-skill-setting-ui.test.ts`. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `<copy>/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `referenLine truncated
|
||||||
|
|
||||||
@@ -183,6 +185,18 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent
|
|||||||
|
|
||||||
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits.
|
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits.
|
||||||
|
|
||||||
|
### Auto-compact and auto-clear thresholds
|
||||||
|
|
||||||
|
**The trigger reads the context size, and the decision is pure** (`decideAutoThreshold` in `src/session-auto-ops.ts`). `POST /api/status-telemetry` hands `context_window.total_input_tokens` to `Session.noteContextReport()`, which runs both checks; that call is the only thing that runs them on a report, because the token paths only fire when the counter moves. A report wins over the token counter, which leaves out cache reads. The counter stays the fallback for every launch without the exporter: non-Claude CLIs, plan-usage collection off, remote and Docker launches, and workspaces with their own statusline.
|
||||||
|
|
||||||
|
**After a /compact or /clear, the report from before the command is ignored by value, not by time.** A report carries the usage of the last API response, so that number stays the newest one until the command finishes. The first report that differs came from the finished command (0 on Claude Code 2.1.319) or from a later response. If the command was swallowed, that report is higher and the threshold fires again, so nothing can lock the automation off. A threshold below what a compact leaves behind fires again on the next response, like the counter always did. A counter summed from JSON usage never drops after a compact, so it counts from its value at the last one. The pane parser's counter is the absolute number on screen and only moves past its highest value, so any move clears that baseline (`noteAbsoluteCounter()`). Neither value survives a restart.
|
||||||
|
|
||||||
|
**The decision is made again when the command goes out** (`_decideAtSend()`). A report fires the trigger in the middle of a turn, and the command then waits for that turn to end (`executeWhenIdle`). What was true at the trigger can be false by then: Claude Code compacted by itself and the report fell (a `/clear` at 140k would otherwise wipe a conversation that is back at 20k), the option was switched off or its threshold raised, or the turn ended on a usage limit. So each action first asks again and returns without sending when the answer is no. That return stamps nothing (neither the report's value nor the counter's baseline), the cooldown releases the flag, and the next report is judged like any other. The counter never falls, so on the fallback the second answer repeats the first.
|
||||||
|
|
||||||
|
**Nothing is sent into a usage-limit pause** (`_heldByLimit()`: from the limit message until auto-resume's `continue` went out, the gap after its Escape included). A `/clear` sent there is followed by that `continue`, typed into an empty conversation; the respawn controller blocks its own `/clear` for the same reason (`isLimitPaused` in `onIdleDetected`). The checks start nothing while it holds, and an action that waited through the start of one returns unsent. Only auto-resume watches for the limit message, so with auto-resume off nothing is held.
|
||||||
|
|
||||||
|
**Thresholds go up to the reported window** (`_maxThreshold()`, 1M when unknown, never below the 1,000 minimum), and a higher value stops at the window when it is set. A threshold set before the window was known can sit above it and never fire; Claude Code's own compaction still does.
|
||||||
|
|
||||||
### Prompt-cache keep-warm
|
### Prompt-cache keep-warm
|
||||||
|
|
||||||
**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its Line truncated
|
**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its Line truncated
|
||||||
@@ -249,7 +263,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
|||||||
|
|
||||||
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
||||||
|
|
||||||
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode, copilot and pi) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
|
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode, copilot, pi and jcode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
|
||||||
|
|
||||||
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
||||||
|
|
||||||
@@ -308,7 +322,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
|||||||
|
|
||||||
⚠️ **The workspace-trust dialog is not a composer.** Its `❯` sits on `No, exit`, so it arms the confirmation, and the dialog is quiet with no working line, so a fresh case used to be concluded idle about 4 s after launch while the dialog was still up (measured on Claude Code 2.1.294: `GET .../wait?until=idle` answered `idle` at 3.8 s in 6 of 12 fresh-dir launches). An agent that waited for idle and then prompted typed into the dialog, and the prompt's Enter confirmed `No, exit`: the pane died and the wait reported `exit`. `_probePaneWorking()` therefore also records whether its capture shows `isTrustDialogScreen()` (a failed capture keeps the last answer, since it proves nothing), and `_confirmIdle()` re-checks on `PANE_PROBE_RECHECK_MS` instead of concluding while it does, but only inside the auto-accept's own `TRUST_DIALOG_WINDOW_MS`: a later screen that merely quotes the dialog must not hold a session busy. The status is left alone rather than marked working, since nothing is running. Tests: `test/session-trust-dialog.test.ts`.
|
⚠️ **The workspace-trust dialog is not a composer.** Its `❯` sits on `No, exit`, so it arms the confirmation, and the dialog is quiet with no working line, so a fresh case used to be concluded idle about 4 s after launch while the dialog was still up (measured on Claude Code 2.1.294: `GET .../wait?until=idle` answered `idle` at 3.8 s in 6 of 12 fresh-dir launches). An agent that waited for idle and then prompted typed into the dialog, and the prompt's Enter confirmed `No, exit`: the pane died and the wait reported `exit`. `_probePaneWorking()` therefore also records whether its capture shows `isTrustDialogScreen()` (a failed capture keeps the last answer, since it proves nothing), and `_confirmIdle()` re-checks on `PANE_PROBE_RECHECK_MS` instead of concluding while it does, but only inside the auto-accept's own `TRUST_DIALOG_WINDOW_MS`: a later screen that merely quotes the dialog must not hold a session busy. The status is left alone rather than marked working, since nothing is running. Tests: `test/session-trust-dialog.test.ts`.
|
||||||
|
|
||||||
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, antigravity declares the head of its footer hint `? for shortcuts` (`? for sh`, drawn only at rest, so nothing arms the idle check during a turn and a pending shell approval reads as working until it is answered; the composer's `>` is also the echoed prompt's) plus its 8-dot braille spinner row `⣯ Generating...`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
|
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, antigravity declares the head of its footer hint `? for shortcuts` (`? for sh`, drawn only at rest, so nothing arms the idle check during a turn and a pending shell approval reads as working until it is answered; the composer's `>` is also the echoed prompt's) plus its 8-dot braille spinner row `⣯ Generating...`, jcode declares the bare `>` of its `<n>> ` composer (the one cell its turn-ending repaint redraws) plus a braille spinner row carrying an elapsed time, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
|
||||||
|
|
||||||
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
|
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
|
||||||
|
|
||||||
@@ -468,17 +482,29 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
|
|||||||
|
|
||||||
### File Viewer edit mode
|
### File Viewer edit mode
|
||||||
|
|
||||||
**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place — `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes**, so it carries every rule the read surfaces have plus its own:
|
**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place — `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes file contents** (the Files panel's delete, [below](#files-panel-delete), removes whole files and borrows these confinement rules), so it carries every rule the read surfaces have plus its own:
|
||||||
|
|
||||||
- **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded). ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface.
|
- **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded from `EDITABLE_EXTENSIONS`). ⚠️ **Dotenv is the one sensitive family the editor opens**: both the gate (`assertEditableTarget`) and the `editable` flag on the plain preview call `isBlockedAttachmentPath(..., { allowDotenv: true })`, which skips only `DOTENV_PATTERNS` in `sensitive-path.ts`, and `isEditableFileName` accepts `.env`, `.env.*` and `*.env` by NAME. ⚠️ Never add `env` to `EDITABLE_EXTENSIONS`: it is also `TEXT_ATTACHMENT_EXTENSIONS`, so it would let cross-workspace attachments read any `prod.env` on the host, which no sensitive pattern matches. The reason is that the plain preview and `file-raw` never consult the blocklist (realpath containment is their control), so refusing the edit protected nothing the preview had not already shown. Every other credential pattern still refuses, so does a dotenv file inside a blocked tree (`.ssh/.env`, a config backup), and the data dir's own `.env` (the `codeman attach` login fallback) has its own entry in `SENSITIVE_PATTERNS` so a session whose workspace is `$HOME` cannot open it. Downloads, attachments and the path picker keep the full list. Keep the two call sites in step. Accepted limits: `/\.env\./` also matches a FOLDER named `.env.<x>`, so files under one become editable too (the preview already shows them); and like every `.codeman[^/]*` entry, the data-dir entry misses a data dir moved by `CODEMAN_DATA_DIR` to a folder not named `.codeman*`, or reached through a symlinked `~/.codeman`, which already left that dir's `users.json`/`settings.json` editable when it sits inside a workspace. ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface.
|
||||||
- **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content.
|
- **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content.
|
||||||
- **Concurrency is optimistic by content hash, not mtime.** The client echoes the sha256 it loaded (`baseHash`); mismatch → 409 CONFLICT (plain envelope — the error arm carries no data; the client re-fetches `edit=1` for fresh state) unless `force:true`. mtime alone is wrong: agents rewrite files within one timestamp tick.
|
- **Concurrency is optimistic by content hash, not mtime.** The client echoes the sha256 it loaded (`baseHash`); mismatch → 409 CONFLICT (plain envelope — the error arm carries no data; the client re-fetches `edit=1` for fresh state) unless `force:true`. mtime alone is wrong: agents rewrite files within one timestamp tick.
|
||||||
- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is *inside* the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content.
|
- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; before it a best-effort `fchown` back to the old file's user and group (group alone when the user cannot be set), because the rename publishes a NEW file that would take the server's own group, and a `0640` `.env` owned `you:www-data` came back `you:you`; a symlink whose target is *inside* the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content.
|
||||||
- **Corruption guards**: NUL-sniff + UTF-8 **round-trip compare** (`Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)`) refuse binary and non-UTF-8 files — decoding latin-1 yields U+FFFD replacements and writing those back destroys the original bytes. EOL is detected server-side and re-applied on save because a `<textarea>` normalizes to LF (a two-line edit of a CRLF file must not become a whole-file diff).
|
- **Corruption guards**: NUL-sniff + UTF-8 **round-trip compare** (`Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)`) refuse binary and non-UTF-8 files — decoding latin-1 yields U+FFFD replacements and writing those back destroys the original bytes. EOL is detected server-side and re-applied on save because a `<textarea>` normalizes to LF (a two-line edit of a CRLF file must not become a whole-file diff).
|
||||||
- **Two size caps on the wire**: the Zod `.max()` counts UTF-16 code units (coarse pre-filter, 400) while the handler's `Buffer.byteLength` check enforces the real byte cap (413); the route sets `bodyLimit: 4MB` because JSON escaping can expand 512KB of content past Fastify's 1MB default. Error paths **throw** structured `{statusCode, body}` errors (`throwFileEditError`) rather than returning envelopes — the central preSerialization status-mapping hook is absent from the route-test harness, and 413 has no errorCode mapping at all.
|
- **Two size caps on the wire**: the Zod `.max()` counts UTF-16 code units (coarse pre-filter, 400) while the handler's `Buffer.byteLength` check enforces the real byte cap (413); the route sets `bodyLimit: 4MB` because JSON escaping can expand 512KB of content past Fastify's 1MB default. Error paths **throw** structured `{statusCode, body}` errors (`throwFileEditError`) rather than returning envelopes — the central preSerialization status-mapping hook is absent from the route-test harness, and 413 has no errorCode mapping at all.
|
||||||
|
|
||||||
Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write-routes.test.ts` (deliberately **unmocked fs** against a real temp workspace — symlink/TOCTOU/mode behavior must be exercised for real).
|
Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write-routes.test.ts` (deliberately **unmocked fs** against a real temp workspace — symlink/TOCTOU/mode behavior must be exercised for real).
|
||||||
|
|
||||||
|
### Files panel delete
|
||||||
|
|
||||||
|
**Files panel delete** (salvaged from contributor PR #529, which was closed; its upload half was dropped because `.codeman-uploads/` and the Attach button already cover it): a × on each file row of the Files panel, tree and search results alike, backed by `DELETE /api/sessions/:id/files?path=` in `file-routes.ts`. It removes ONE regular file and is the only file surface that deletes, so it takes the edit route's confinement whole and adds a rule of its own.
|
||||||
|
|
||||||
|
- **Order of checks**: `findSessionOrFail` (ownership; a foreign session is a 404 in multi-user mode) → remote refused (400, before any path check, for the same reason as the edit route: `validateSessionFilePath` resolves against the LOCAL filesystem, so a same-named local folder would take the delete) → adopted refused (400) → a missing or repeated `path` (400) → a bounded probe of `workingDir`, and of the outside worktree root for an absolute path (#516; `422` on a mount that does not answer) → `validateLocalSessionFilePath(await withGuardedOutsideWorktree(...))` (realpath + boundary, an absolute path inside a followed outside worktree confined to it, the attachment guard re-read per request; any escape, a dangling link and a missing file are all 404) → sensitive / blocked trees / `.git` (403), each tested against BOTH the realpath and the path as named, so a `.git` reached through a linked folder and a linked `.git` are both caught → the link rule below → not a regular file (a folder: 400).
|
||||||
|
- **A symbolic link is refused, never followed.** The confinement helper returns the REALPATH, which for a link is its target. #529 unlinked that path, so `DELETE ?path=AGENTS.md` with `AGENTS.md -> CLAUDE.md` deleted `CLAUDE.md` and left `AGENTS.md` dangling. The route therefore `lstat`s the path as the client named it (`resolve(workingDir, path)`, which keeps an absolute outside-worktree path as it is) and answers 400 for a link. The listing shows a link to a folder as a file row, so it gets a button, and the same 400 answers it. Deleting the link itself was the other option (confine the PARENT's realpath, unlink the link); it was not taken because refusing has no way to touch the target at all.
|
||||||
|
- **The file checked is the file deleted.** A path whose last part is not a link has a realpath naming the same inode, so the route `lstat`s the realpath again and refuses with 409 if the inode, the device or the file type changed in between. `unlink` never follows a final symlink, so a link swapped in after that still removes only the link. A parent folder swapped for a link between the realpath and the `unlink` is the same check-then-use window every file route accepts (an agent running as the same user can delete the file anyway).
|
||||||
|
- **Adopted and Docker.** An adopted session's folder is the neutral `/tmp`. The older file surfaces keep serving it as a standing exception (root `CLAUDE.md`, Adopted tmux sessions), which is not copied to a new surface, least of all one that deletes; the prompt-upload route refuses adopted sessions the same way. A Docker case workspace is bind-mounted at the same path and an `unlink` runs no hooks or filters (the reason git is kept away from those folders), so the route treats it like the edit route does: allowed.
|
||||||
|
- **Frontend.** The button carries no inline handler: `encodeURIComponent` leaves `'` alone, so #529's `onclick="...decodeURIComponent('<path>')"` let a file named `x'-alert(1)-'.txt` run script in Codeman's origin, and `it's.txt` was a syntax error. It is bound in the existing `.file-tree-item` loops (`renderFileBrowserTree`, `_renderFileBrowserSearchResults`) from the row's escaped `data-path`, with `stopPropagation` so the row does not also open the preview. `deleteFileFromBrowser()` asks with `confirm()` (the panel's other destructive actions use the same native dialog, translated by i18n.js's wrapper) and reloads through `_reloadFileBrowserKeepingQuery()`, which re-runs a search in the box, so a row deleted from search results leaves them. The row buttons stay visible under `@media (hover: none)`, where the old hover-only rule meant the first tap opened the preview instead. `_fileBrowserCanDelete()` draws no button for a remote or an adopted session (`session.remote` is undefined on an adopted one, so `session.adopt` is tested too).
|
||||||
|
|
||||||
|
Tests: `test/routes/file-delete-routes.test.ts` (unmocked fs again: the symlink defect only shows against a real link), `test/file-delete-ui.test.ts` (jsdom with `runScripts: 'dangerously'`, so the old inline handler would really run there, and the real `escapeHtml` from constants.js).
|
||||||
|
|
||||||
### File Viewer text view: rendered markdown and text toggles
|
### File Viewer text view: rendered markdown and text toggles
|
||||||
|
|
||||||
**Rendered markdown + Lines/Wrap** (`_renderFilePreviewText()` and its helpers in `panels-ui.js`, buttons in the `.file-preview-actions` row): a `.md`/`.markdown` opened in the File Viewer renders as a document by default, with an `MD` pill back to source; the plain-text view has `Lines` (a CSS-counter gutter) and `Wrap` toggles. Codeman already had `marked` + DOMPurify behind `_renderMarkdown()` for the Response Viewer, so the viewer reuses that and the codebase keeps ONE markdown pipeline.
|
**Rendered markdown + Lines/Wrap** (`_renderFilePreviewText()` and its helpers in `panels-ui.js`, buttons in the `.file-preview-actions` row): a `.md`/`.markdown` opened in the File Viewer renders as a document by default, with an `MD` pill back to source; the plain-text view has `Lines` (a CSS-counter gutter) and `Wrap` toggles. Codeman already had `marked` + DOMPurify behind `_renderMarkdown()` for the Response Viewer, so the viewer reuses that and the codebase keeps ONE markdown pipeline.
|
||||||
@@ -714,21 +740,31 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse
|
|||||||
|
|
||||||
**The coding agent** is installed by `runInstallCommand()` of `src/cli-install-runner.ts`, the runner extracted from `cli-registry-routes.ts` (whose route keeps its behaviour and re-exports `installEnv`/`npmGlobalPrefixWritable` for its tests). ⚠️ The job runs only `stockInstallCommand(id)`, read from the COMPILED `STOCK_CLIS`, never the registry merged with `clis.json` (the route's own command still comes from the merged registry, behind its `cliManagementEnabled` gate): this path has no such gate, so an override file must not be able to change what it runs. `HarnessFact.installCommand` is that same string, which is what the consent screen prints. ⚠️ Only for a harness row of the catalogue, only after the consent click, admin only, audited (`cli_install` with `via: 'local-model'`). ⚠️ ONE install slot per CLI id (`tryBeginCliInstall()`), shared by the route and the job. ⚠️ After the installer, successful or not, the resolver caches are forgotten (`forgetResolvedCli()`) and the binary is looked for again: a pre-install miss is negative-cached for up to five minutes, and "Start a session" would otherwise answer "not installed". Still missing is `harness-failed`; found is success whatever the exit code. The runner takes an abort signal that kills the installer's process GROUP.
|
**The coding agent** is installed by `runInstallCommand()` of `src/cli-install-runner.ts`, the runner extracted from `cli-registry-routes.ts` (whose route keeps its behaviour and re-exports `installEnv`/`npmGlobalPrefixWritable` for its tests). ⚠️ The job runs only `stockInstallCommand(id)`, read from the COMPILED `STOCK_CLIS`, never the registry merged with `clis.json` (the route's own command still comes from the merged registry, behind its `cliManagementEnabled` gate): this path has no such gate, so an override file must not be able to change what it runs. `HarnessFact.installCommand` is that same string, which is what the consent screen prints. ⚠️ Only for a harness row of the catalogue, only after the consent click, admin only, audited (`cli_install` with `via: 'local-model'`). ⚠️ ONE install slot per CLI id (`tryBeginCliInstall()`), shared by the route and the job. ⚠️ After the installer, successful or not, the resolver caches are forgotten (`forgetResolvedCli()`) and the binary is looked for again: a pre-install miss is negative-cached for up to five minutes, and "Start a session" would otherwise answer "not installed". Still missing is `harness-failed`; found is success whatever the exit code. The runner takes an abort signal that kills the installer's process GROUP.
|
||||||
|
|
||||||
|
**The coding agent is chosen per session.** The launch check (`launchRefusalMessage()`) asks about the endpoint and the model, never about the agent, so any CLI with a custom model recipe could always be started on `local-ollama` from the Run menu. What the feature adds is the list: `install.harnesses` (`buildHarnessRows()`, pure) is every catalogue harness that is not gated, has a `HarnessFact`, works at the model's context and accepts its name, each `ready`, `installable`, `installing` or `setup-needed`. ⚠️ `install.cli` stays the DEFAULT agent for a client that names none (the 1.50.0 skill reads only that field): the agent of the installation while it can be started, else the first row that can, so it never names an agent that is gone while another is there. ⚠️ `start-session` is listed only while a row is `ready`, once the rows are known; rows that could not be built (the registry threw) are "unknown", never "none", and a session is still offered. ⚠️ A LAUNCHER (`discovery.launcherProfile`, DeepSeek Harness) is never `installable`: its stock command installs the launcher and leaves it with nothing to launch, and the profile it needs is third-party code behind the privileged grant of `POST /api/deepseek/install-profile`. The rule reads the entry's data, never its id, and such an agent is offered only once the Run menu can start it. ⚠️ `POST /api/local-model/agent` (`installAgent()`) is NOT a job and NOT an event of the reducer: a job would put a working model into `failed` on an `npm` error and refuse every session until it was cleared. It runs `installCodingAgent()`, the function the `agent` step runs (same slot, same compiled command, same audit line), in the background; the state file never hears of it, the outcome lives in memory (`agentInstall`, `agentFailures`), one installer at a time, and `shutdown()` aborts it. A harness row may carry `readyMark` (a literal the composer draws once it takes input, for a script's `wait-output`): only a MEASURED mark goes there, the registry's `composerReadyMark` stands in when the row has none, and Pi has none on purpose (its composer rules are painted before it takes Enter, measured on 1.5.2).
|
||||||
|
|
||||||
**The connection** (`connect.ts`): one record `{ id: 'local-ollama', label: 'This Mac', baseUrl, authStyle: 'bearer', models: [<id as /v1/models lists it>], defaultModelId, defaultContextLength: <the chosen context> }`, validated by `CustomModelHostSchema` and written through `mutateCustomModelHosts()`, then `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, with the flag's previous value kept in the state file (a second connect must hand the FIRST value back, or Remove could never switch the flag off again). ⚠️ `defaultContextLength` is load-bearing: the runtime's model listing states no context, so this field is what reaches the coding agent as its limit. ⚠️ The id is reserved: `POST /api/model-endpoints` refuses it, and a record with it that this feature did not write is never overwritten (`endpoint-id-taken`). ⚠️ While the state file records the connection, the record is MANAGED (`isManagedLocalEndpoint()`): `PUT`/`DELETE /api/model-endpoints/local-ollama` are refused, discover answers the stored list without a request, and the sweep skips it, so a model somebody pulled through the port never becomes launchable and the URL never leaves the supervised port. A hand-made record with that id (no state) stays an ordinary endpoint. `local-model.json` is not a config-backup file while the endpoint store and the settings are, so a restore can take the record away under an installed model: `boot()` puts the RECORD back when it is gone (`ensureLocalEndpoint()`, never replacing one that is there) and leaves the settings flag as it finds it. ⚠️ The runtime's port has NO authentication, and its CORS allowlist always admits `localhost` origins: any local process or localhost page can pull, create or delete models there. That is the reason for the three rules above and for the digest.
|
**The connection** (`connect.ts`): one record `{ id: 'local-ollama', label: 'This Mac', baseUrl, authStyle: 'bearer', models: [<id as /v1/models lists it>], defaultModelId, defaultContextLength: <the chosen context> }`, validated by `CustomModelHostSchema` and written through `mutateCustomModelHosts()`, then `customModelEndpointsEnabled: true` through `mutateSettingsFile()`, with the flag's previous value kept in the state file (a second connect must hand the FIRST value back, or Remove could never switch the flag off again). ⚠️ `defaultContextLength` is load-bearing: the runtime's model listing states no context, so this field is what reaches the coding agent as its limit. ⚠️ The id is reserved: `POST /api/model-endpoints` refuses it, and a record with it that this feature did not write is never overwritten (`endpoint-id-taken`). ⚠️ While the state file records the connection, the record is MANAGED (`isManagedLocalEndpoint()`): `PUT`/`DELETE /api/model-endpoints/local-ollama` are refused, discover answers the stored list without a request, and the sweep skips it, so a model somebody pulled through the port never becomes launchable and the URL never leaves the supervised port. A hand-made record with that id (no state) stays an ordinary endpoint. `local-model.json` is not a config-backup file while the endpoint store and the settings are, so a restore can take the record away under an installed model: `boot()` puts the RECORD back when it is gone (`ensureLocalEndpoint()`, never replacing one that is there) and leaves the settings flag as it finds it. ⚠️ The runtime's port has NO authentication, and its CORS allowlist always admits `localhost` origins: any local process or localhost page can pull, create or delete models there. That is the reason for the three rules above and for the digest.
|
||||||
|
|
||||||
**The digest** `/api/tags` reported after the check is stored. ⚠️ Before a session starts on the managed endpoint (`LocalModelService.launchRefusalMessage()`, asked by BOTH launch paths in `session-routes.ts`: `POST /api/sessions/:id/custom-model` and the quick-start `customModel` branch, before anything is written or created) and at boot it is read again: a different digest, a model that is gone, a runtime Codeman did not start, or a model id other than the installed one refuses the launch with one sentence. The view shows what blocks a session as `error` while the state stays `installed`; it is found live and never stored. Boot recovery of an already running session (`_rebuildCustomModelEnv`) is not guarded: the pane exists and keeps its environment.
|
**The digest** `/api/tags` reported after the check is stored. ⚠️ Before a session starts on the managed endpoint (`LocalModelService.launchRefusalMessage()`, asked by BOTH launch paths in `session-routes.ts`: `POST /api/sessions/:id/custom-model` and the quick-start `customModel` branch, before anything is written or created) and at boot it is read again: a different digest, a model that is gone, a runtime Codeman did not start, or a model id other than the installed one refuses the launch with one sentence. The view shows what blocks a session as `error` while the state stays `installed`; it is found live and never stored. Boot recovery of an already running session (`_rebuildCustomModelEnv`) is not guarded: the pane exists and keeps its environment.
|
||||||
|
|
||||||
**Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove.
|
**Remove** stops the child, undoes the connection FIRST (a refused write of the endpoint store or of the settings rejects before any file is deleted), restores the flag only when this feature switched it on AND no other endpoint is saved, then deletes the runtime and the private home and, when asked, the models (`removeLocalModelFiles()`, which refuses any path outside the root). ⚠️ While a live session uses the endpoint the answer is 409 `sessions-open` with the session names (`sessionNamesUsingEndpoint()`, read at the moment of the request). ⚠️ The coding agent stays: what its installer wrote is not ours to remove.
|
||||||
|
|
||||||
|
**The library** (one model in use, several on disk). `LocalModelStateFile.library` lists every model that PASSED the check through Codeman: `{ id, label, context, digest, bytes?, cli?, verifiedAt? }`, no path. The service upserts the entry as a fact when `verify` passes (where it stores the digest); the reducer only carries the list, through every event but `remove`. An `installed` file an earlier build wrote has no list: `libraryOf()` then counts its one model, and an install or a switch from it writes that model into the list before the state's `model` member becomes the new job's. ⚠️ ONE model is in use: the endpoint record still lists exactly one model, the runtime still holds one (`OLLAMA_MAX_LOADED_MODELS=1`), and `launchRefusalMessage()` still starts only the state's model with its stored digest. Never widen the record to "every checked model": the port has no login, and two sessions on two models would make the runtime swap 20 GB on every request.
|
||||||
|
|
||||||
|
⚠️ Three verbs, each a row of the tables and never a special case. **Add**: `install` is also listed for `installed` in `INSTALL_TRANSITIONS`; the job is the same five steps, and the earlier model stays (the `replacedModelId` rule, `replacedModelFor()`, never names a model of the library: only an unchecked or partial download is deleted as a leftover). **Switch**: the event `use` (`installed`, `paused`, `failed`) starts a job of `USE_JOB_STEPS` (`verify`, `connect`) for a library entry, carrying its stored digest in `model.digest`; `stepVerify` compares that digest BEFORE it loads anything and again after the check, and a different or missing one fails with `model-changed`. A switch has no `model` step, so the leftovers of the job it replaced are deleted at the start of `verify` (`dropReplacedModel()`). Its coding agent is the entry's own when that is installed now, else the state's (`recheckCliInstalled()`), else a 400: a job that stopped while it installed another agent leaves the state naming one that is not there. **Delete** (`deleteLibraryModel()`) moves no state, so it is not an event; whether a state allows it is the `delete-model` action of its `INSTALL_STATE_TABLE` row. It takes a model NAME (the request schema and the service both hold it to `LOCAL_MODEL_ID_PATTERN`), requires that name in the server's own listing (the library or the runtime's tags), goes through the runtime's `/api/delete` only (never a file delete, never a path), and trusts only the runtime's answer that the model is gone, never an empty listing. ⚠️ It refuses the model of the installation or of the current job (`model-active`), a running job, and a runtime that is down or does not answer (`runtime-not-running`).
|
||||||
|
|
||||||
|
⚠️ `install` from `installed` and `use` are refused with 409 `sessions-open` while live sessions use the endpoint, like Stop: both change which model the runtime holds and often restart it for another context. ⚠️ While a job that started from `installed` is unfinished (`installing`, `paused`, `failed`) the launch check refuses every start with `job-unfinished` (a code only a refused start says, like `runtime-not-running`), because it wants the state `installed`; the view then offers `use` on the other checked models, which is the way back. A switch is never offered for the model of the stopped job itself: Resume goes on with that one. ⚠️ Both verbs undo a Stop (`undoStop()`, `runtime.enabled` back to true) once their job started: the job runs the runtime, and a model that is in use and running must also start with the server again.
|
||||||
|
|
||||||
|
**The view's two listings.** `install.library` (`buildLibraryRows()`, pure) joins the state's entries with what the RUNNING runtime lists (`/api/tags`): `ready`, `changed` (another digest under the name), `missing` (no longer listed) for a checked model, `unverified` for anything else the runtime lists (a model an earlier Remove kept, the model of a job that has not passed its check, one another program pulled through the unauthenticated port), which can be deleted and never switched to. A name that is not a plain `name:tag` is listed with no action. `install.store` is `measureModelStore()`: the sizes of the regular files directly in `models/blobs`, nothing followed, nothing outside the root. ⚠️ Neither is asked for on the one-second poll: `tagsCache` (5 s) and `storeCache` (30 s) are refreshed in the background, only a figure that was never read is waited for (300 ms at most), a runtime that does not answer keeps the last list (or every checked model would read as `missing` for a moment), and every verb and finished step that changes the store throws both away (`forgetStore()`). ⚠️ In `idle` there is no runtime to ask, so the list is empty and only `store` says that an earlier Remove kept models; `remove` is listed there only while it holds something, and `DELETE /api/local-model { removeModel: true }` is what frees it. `Candidate.library` (`active`, `ready`) is added by the ROUTE (`markLibraryCandidates()`), never by `recommend()`, which stays pure and knows nothing of what is installed.
|
||||||
|
|
||||||
**Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`.
|
**Developer tools**: `checkDeveloperTools()` runs ONLY `/usr/bin/xcode-select -p`, bounded, on darwin. ⚠️ Never `git`, `python3` or `xcrun` from the server: on a Mac without the tools each opens a system window. The view carries `developerTools`, and the window prints the one sentence when it is `missing`.
|
||||||
|
|
||||||
**The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it.
|
**The endpoint store's writer** (`src/custom-model-hosts-writer.ts`): `mutateCustomModelHosts(fn)` is the shape of `mutateSettingsFile()`: one promise chain, a random temp name, a refusal (409) to write over a file that does not parse. ⚠️ EVERY writer of `custom-model-hosts.json` goes through it: the four endpoint routes, the sweep and the connect step (`test/custom-model-hosts-writer.test.ts` fails a `writeCustomModelHosts()` call anywhere else in `src/`). The routes used to read, change and write on their own through one shared temp name, so two creates sent together lost a record or failed, and a file that did not parse was read as `[]` and replaced by one record, with every other endpoint's API key gone. ⚠️ A route's check runs INSIDE the callback, and discover splices onto the record as it is after the probe, not the copy read before it.
|
||||||
|
|
||||||
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event), `test/local-model-install-service.test.ts`, `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
|
Tests: `test/local-model-install-job.test.ts` (the tables and the reducer, every state with every event, the library rows), `test/local-model-install-service.test.ts` (the three library verbs, the agent install and the two caches included), `test/local-model-install-e2e.test.ts` (the real modules over the fake runtime: install A, add B, switch back, delete B), `test/local-model-state-store.test.ts`, `test/local-model-connect.test.ts`, `test/custom-model-hosts-writer.test.ts`, `test/cli-install-runner.test.ts`, `test/routes/local-model-routes.test.ts`, `test/routes/local-model-launch-guard.test.ts`, and the runtime's own `test/local-model-runtime.test.ts`, `test/local-model-ollama-client.test.ts`. The fake runtime is `test/fixtures/fake-ollama.ts`.
|
||||||
|
|
||||||
### Approvals Inbox
|
### Approvals Inbox
|
||||||
|
|
||||||
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`).
|
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge, omp's approval hook module and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). ⚠️ An omp item takes the pane frame ONCE as `context` and gets no `capture`, like a screen-read one: the re-read is shaped for Claude's numbered dialog, omp's has no numbered rows, and a numbered list in the agent's own output above it was parsed as the item's options, so the item was resolved as answered once that list scrolled out of the frame while omp still waited. Only the module's `elicitation_complete` clears it. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`).
|
||||||
|
|
||||||
⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409).
|
⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409).
|
||||||
|
|
||||||
@@ -782,7 +818,7 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`
|
|||||||
|
|
||||||
⚠️ Capture MUST be linear16/16 kHz/mono, so it uses an **AudioWorklet**, not MediaRecorder (which cannot emit raw PCM); `voice-pcm-worklet.js` is fetched from JS, so it is invisible to `cacheBustAssets` and borrows voice-input.js's `?v=` token — **edit the two together**.
|
⚠️ Capture MUST be linear16/16 kHz/mono, so it uses an **AudioWorklet**, not MediaRecorder (which cannot emit raw PCM); `voice-pcm-worklet.js` is fetched from JS, so it is invisible to `cacheBustAssets` and borrows voice-input.js's `?v=` token — **edit the two together**.
|
||||||
|
|
||||||
⚠️ Transcript frames carry the WHOLE running transcript, not deltas: the Claude path replaces where the Deepgram path appends. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.md`
|
⚠️ Claude transcript frames are cumulative within the current utterance: replace interims, then append each endpoint final once. An endpoint does not stop dictation unless finalization was requested. Finalization has a 5-second client backstop; reconnect cleanup salvages accumulated/interim text and releases the microphone immediately. Provider choice is `voiceSettings.provider` (`auto` prefers Claude → Deepgram → Web Speech). → `docs/claude-voice-plan.md`
|
||||||
|
|
||||||
### Files panel search
|
### Files panel search
|
||||||
|
|
||||||
@@ -872,7 +908,7 @@ Paths, agent descriptions and diff lines render via `textContent` only (browser
|
|||||||
|
|
||||||
⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap.
|
⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap.
|
||||||
|
|
||||||
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
|
⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case) and `lastPickedCase` (a pick in a case picker), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since).
|
||||||
|
|
||||||
⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder).
|
⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder).
|
||||||
|
|
||||||
@@ -944,13 +980,15 @@ Feedback is silent on success except ONCE per page load (a feature that works by
|
|||||||
**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:
|
**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.
|
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.
|
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. That holds for Chromium and WebKit, where it is the only paste. In Firefox the trap cancels the keydown once route 1 has delivered (below), so route 2 no longer happens there.
|
||||||
|
|
||||||
Measured against a live install, one `Ctrl+V` each: Firefox delivers two paste events, Chromium and WebKit one. Handling both wrote the clipboard text to the PTY twice, which is why `Ctrl+V` pasted twice while right-click → Paste pasted once. That menu path involves no keydown, so route 2 cannot exist for it.
|
Measured against a live install before the keydown was cancelled, one `Ctrl+V` each: Firefox delivered two paste events, Chromium and WebKit one. Handling both wrote the clipboard text to the PTY twice, which is why `Ctrl+V` pasted twice while right-click → Paste pasted once. That menu path involves no keydown, so route 2 cannot exist for it.
|
||||||
|
|
||||||
⚠️ **Route 2 alone is enough in all three engines, so route 1 is redundant where it can be measured.** Strip the `execCommand('paste')` call out and each of the three still delivers exactly one paste event to the trap, Firefox included. The call is kept anyway, because the trap technique was written for the mobile engines that a desktop measurement cannot reach, and a browser that resolves the key's default action against the element focused when the keydown began would send its paste to xterm's textarea instead. Text paste survives that on xterm's own `handlePasteEvent`; image paste does not, since the trap's listener is the only place clipboard image blobs are read. Removing the call is therefore a decision about mobile coverage, not a cleanup.
|
⚠️ **Once route 1 has delivered, the trap cancels the keydown.** Firefox dispatches the `execCommand` paste synchronously, inside the call. It delivers the key's own paste as a separate task about 10 ms later, measured in Playwright's Firefox. That later event races the `setTimeout(0)` that removes the trap and refocuses the terminal. When the timer wins, the event lands on xterm's textarea, and xterm's own `handlePasteEvent` sends the clipboard a second time; the trap's one-shot flag never sees that event. This race is why a one-shot flag alone left `Ctrl+V` pasting twice "sometimes". Both callers, the main pane's key handler and a tile's, pass the keydown as `target.keyEvent`. `_handleImagePaste()` calls its `preventDefault()` when the paste listener has already run by the time `execCommand` returns, so the browser never issues route 2. Chromium and WebKit dispatch nothing from route 1, so they keep the default action, which delivers their only paste event. ⚠️ Never cancel the keydown unconditionally: in those engines that loses the paste entirely.
|
||||||
|
|
||||||
The guard is a one-shot flag on each trap rather than a browser test or a reading of `execCommand`'s return value, so any count of events produces one insert. Tests: `test/image-paste-trap.test.ts`.
|
⚠️ **The `execCommand('paste')` call stays.** The trap technique was written for mobile engines that a desktop measurement cannot reach. A browser that resolves the key's default action against the element focused when the keydown began would send its paste to xterm's textarea. Text paste survives that on xterm's own `handlePasteEvent`; image paste does not, since the trap's listener is the only place clipboard image blobs are read. Removing the call is therefore a decision about mobile coverage, not a cleanup.
|
||||||
|
|
||||||
|
The one-shot flag on each trap stays as a second guard, so any count of events that reach the trap produces one insert. Tests: `test/image-paste-trap.test.ts`.
|
||||||
|
|
||||||
### Settings surface: App Settings, Session Options, Add Case
|
### Settings surface: App Settings, Session Options, Add Case
|
||||||
|
|
||||||
@@ -1001,7 +1039,7 @@ Further detail: in the Settings section order, the version and the updater lead
|
|||||||
⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.
|
⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation.
|
||||||
|
|
||||||
⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. The BRIEF view therefore does not render `text`: it asks for `?context=turn`, which returns the assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn()` in `src/web/response-viewer-transcript.ts`: the highest `turn` that has an assistant row, so a prompt queued after the answer does not blank the view), and renders them with the same numeric-`turn` continuation gate as the full view. `text` alone was the final row of a median-3-row turn — a "Done." tail with the substance in the rows before it. Readers that emit no `turn` (Codex, the pane parser, an older server) return `text` only, and the brief view falls back to one card for them. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-<uuid8>` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
|
||||||
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected.
|
**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load. **On a phone it is opt-in (#759):** the handheld branch of `getDefaultSettings()` defaults it OFF, `html.mobile-init` in `mobile.css` hides it until the settings have been applied (the template ships it visible, so it would flash otherwise), and switching it on in App Settings on that phone shows it there with a 44px touch target. `mobile.css` no longer hides it at phone width, so that default and the startup rule are the whole lock; `test/mobile-header-buttons-policy.test.ts` pins both in the gate, and `test/mobile/file-browser.test.ts` drives the real page at 390px. The setting never reaches the server (stripped from the PUT, absent from the schema), so a desktop cannot switch it on for a phone. ⚠️ A device that is not a handheld takes the desktop default at any width, so a desktop window or an iPad split view narrower than 600px now shows the button too. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected.
|
||||||
|
|
||||||
### Session list layout (header strip vs. left sidebar)
|
### Session list layout (header strip vs. left sidebar)
|
||||||
|
|
||||||
@@ -1085,7 +1123,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
|
|||||||
|
|
||||||
**Adding a skin means touching four places, and each one fails silently on its own**: (1) the `html[data-skin="…"]` token block in `styles.css`; (2) the xterm ANSI palette in `CODEMAN_XTERM_THEMES` (terminal-ui.js) — a missing entry falls back to `daylight-blue`, so the terminal simply stays the wrong color; (3) the pre-paint allowlist array in `index.html` — a skin missing there is coerced to `daylight-blue` on every reload, which reads as "my theme keeps resetting"; (4) the `<select>` in App Settings → Appearance → Theme. `test/skin-themes.test.ts` is the static four-way guard.
|
**Adding a skin means touching four places, and each one fails silently on its own**: (1) the `html[data-skin="…"]` token block in `styles.css`; (2) the xterm ANSI palette in `CODEMAN_XTERM_THEMES` (terminal-ui.js) — a missing entry falls back to `daylight-blue`, so the terminal simply stays the wrong color; (3) the pre-paint allowlist array in `index.html` — a skin missing there is coerced to `daylight-blue` on every reload, which reads as "my theme keeps resetting"; (4) the `<select>` in App Settings → Appearance → Theme. `test/skin-themes.test.ts` is the static four-way guard.
|
||||||
|
|
||||||
**Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `<select>` popups, date pickers and scrollbars as dark widgets on a light page. xterm `minimumContrastRatio: 4.5` (set at construction in terminal-ui.js **and** panels-ui.js for teammate terminals, and re-applied in `applyTerminalSkin()`), because CLIs emit colors chosen for dark backgrounds; dark skins keep `1` to avoid the per-cell contrast work. And `applyTerminalSkin()` must call `this._localEchoOverlay?.refreshFont()` — the zero-lag overlay **caches** the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch.
|
**Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `<select>` popups, date pickers and scrollbars as dark widgets on a light page. xterm `minimumContrastRatio: 4.5` (set at construction in terminal-ui.js **and** terminal-tile.js, and re-applied in `applyTerminalSkin()`), because CLIs emit colors chosen for dark backgrounds; dark skins keep `1` to avoid the per-cell contrast work. And `applyTerminalSkin()` must call `this._localEchoOverlay?.refreshFont()` — the zero-lag overlay **caches** the terminal fg/bg at construction, so without the refresh typed-but-not-yet-flushed text keeps the previous theme's dark backing after a live skin switch.
|
||||||
|
|
||||||
**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to *nothing* (invalid at computed-value time), so the aliases are load-bearing, not cosmetic.
|
**Shared surface tokens, not literals.** Elevated/floating surfaces resolve through `--floating-bg`, `--control-bg{,-hover}`, `--control-border{,-hover}`, `--banner-bg-a/b`, `--modal-backdrop` and `--elevated-shadow`, so modals, the command palette, dropdowns, subagent/ultracode windows, file previews and the mobile sheets follow the skin instead of hardcoded near-black rgba. ⚠️ Every skin that wants its own elevated look must override these — a skin that omits `--floating-bg` silently inherits the `:root` value, which is how the OG skin's near-black modals drifted slate. `styles.css` also defines **compatibility aliases** (`--bg-primary/secondary/tertiary`, `--text-primary/secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that forward to the canonical tokens; panels written against those names previously resolved to *nothing* (invalid at computed-value time), so the aliases are load-bearing, not cosmetic.
|
||||||
|
|
||||||
@@ -1217,7 +1255,7 @@ The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTime
|
|||||||
|
|
||||||
⚠️ **The `@font-face` descriptor, not the file, is what the browser synthesizes from**: `fonts/jetbrains-mono-variable.woff2` carries a `wght` axis of 100-800, and while `styles.css` declared it `400 700` every weight below 400 rendered identically to 400 and 800 identically to 700 — measured — so the setting was a no-op for anyone without Fira Code or Cascadia Code installed, which is most installs. It is declared `100 800`; re-narrowing it silently guts the feature (`test/terminal-font-weight.test.ts` pins the range).
|
⚠️ **The `@font-face` descriptor, not the file, is what the browser synthesizes from**: `fonts/jetbrains-mono-variable.woff2` carries a `wght` axis of 100-800, and while `styles.css` declared it `400 700` every weight below 400 rendered identically to 400 and 800 identically to 700 — measured — so the setting was a no-op for anyone without Fira Code or Cascadia Code installed, which is most installs. It is declared `100 800`; re-narrowing it silently guts the feature (`test/terminal-font-weight.test.ts` pins the range).
|
||||||
|
|
||||||
⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and open Agent Teams panes** (they read their options at construction, exactly like `applyTerminalSkin()` propagates).
|
⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and every open tile and split pane** (`_forEachTile`: they read their options at construction, exactly like `applyTerminalSkin()` propagates).
|
||||||
|
|
||||||
⚠️ `_awaitTerminalFont()` is deliberately untouched: `CharSizeService` measures through the CSS `font` shorthand, which RESETS the weight, so the measured face is always the 400 one and a weighted descriptor would ask for nothing new.
|
⚠️ `_awaitTerminalFont()` is deliberately untouched: `CharSizeService` measures through the CSS `font` shorthand, which RESETS the weight, so the measured face is always the 400 one and a weighted descriptor would ask for nothing new.
|
||||||
|
|
||||||
@@ -1283,7 +1321,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
|||||||
|
|
||||||
### PTY and browser terminal geometry
|
### 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** (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 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 the tiles 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 swipe reaLine 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.
|
**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.
|
||||||
|
|
||||||
@@ -1301,7 +1339,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
|||||||
|
|
||||||
### Z-index layers
|
### Z-index layers
|
||||||
|
|
||||||
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, `#folderGridOverlay` — above the response viewer a folder link opens it from, below the file preview its tiles open, so a tile's preview and its ←/→ paging sit on top of the grid), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
|
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, `#folderGridOverlay` — above the response viewer a folder link opens it from, below the file preview its tiles open, so a tile's preview and its ←/→ paging sit on top of the grid), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
|
||||||
|
|
||||||
## Security layers
|
## Security layers
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ This guide documents the browser testing infrastructure, framework comparison re
|
|||||||
# Run standalone benchmark (recommended - avoids vitest hook issues)
|
# Run standalone benchmark (recommended - avoids vitest hook issues)
|
||||||
npx tsx scripts/browser-comparison.mjs
|
npx tsx scripts/browser-comparison.mjs
|
||||||
|
|
||||||
# Run existing browser E2E tests
|
# Run the browser suite (Playwright; the files are listed in config/test-suites.ts)
|
||||||
npm test -- test/browser-e2e.test.ts
|
npm run test:browser
|
||||||
```
|
```
|
||||||
|
|
||||||
## Framework Comparison Results
|
## Framework Comparison Results
|
||||||
@@ -67,7 +67,7 @@ We tested three browser automation frameworks against the Codeman web UI:
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Use the existing browser-e2e.test.ts pattern** which uses agent-browser CLI commands via `execSync` (avoids async hook issues)
|
3. **Drive agent-browser through its CLI** with `execSync`, as `scripts/browser-comparison.mjs` does (avoids async hook issues)
|
||||||
|
|
||||||
## Test File Structure
|
## Test File Structure
|
||||||
|
|
||||||
@@ -93,10 +93,8 @@ fixed ports 3180-3182.
|
|||||||
|
|
||||||
| File | Framework | Status |
|
| File | Framework | Status |
|
||||||
|------|-----------|--------|
|
|------|-----------|--------|
|
||||||
| `test/browser-e2e.test.ts` | agent-browser | ✅ Working |
|
| `test/*.browser.test.ts` and the other files in `BROWSER_TEST_GLOBS` (`config/test-suites.ts`) | Playwright | ✅ `npm run test:browser` |
|
||||||
| `test/browser-playwright.test.ts` | Playwright | ⚠️ Vitest hook issues |
|
| `test/mobile/**` | Playwright | ✅ `npm run test:mobile` |
|
||||||
| `test/browser-puppeteer.test.ts` | Puppeteer | ⚠️ Vitest hook issues |
|
|
||||||
| `test/browser-agent.test.ts` | agent-browser | ⚠️ Vitest hook issues |
|
|
||||||
| `scripts/browser-comparison.mjs` | All three | ✅ Working (standalone) |
|
| `scripts/browser-comparison.mjs` | All three | ✅ Working (standalone) |
|
||||||
|
|
||||||
## Framework-Specific Patterns
|
## Framework-Specific Patterns
|
||||||
|
|||||||
+11
-3
@@ -1,6 +1,6 @@
|
|||||||
# The CLI registry
|
# The CLI registry
|
||||||
|
|
||||||
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness, OMP and GitHub Copilot CLI — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead.
|
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness, OMP, GitHub Copilot CLI and jcode — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead.
|
||||||
|
|
||||||
## Where it lives
|
## Where it lives
|
||||||
|
|
||||||
@@ -223,11 +223,15 @@ still reading. A caller that asked only for `idle` keeps getting `idle`.
|
|||||||
|
|
||||||
`agentSkillInjection` says a CLI's sessions get the agent skill (`skills/codeman`) and its per-session preamble when the synced `agentSkillEnabled` setting is on; `agentSkillDir` says which workspace directory the CLI scans for skills, read through `agentSkillDir()` (hooks-config.ts), so no call site branches on a CLI id. Absent with the boolean on means `.claude/skills`, which is what the boolean meant before the field existed.
|
`agentSkillInjection` says a CLI's sessions get the agent skill (`skills/codeman`) and its per-session preamble when the synced `agentSkillEnabled` setting is on; `agentSkillDir` says which workspace directory the CLI scans for skills, read through `agentSkillDir()` (hooks-config.ts), so no call site branches on a CLI id. Absent with the boolean on means `.claude/skills`, which is what the boolean meant before the field existed.
|
||||||
|
|
||||||
|
`approvalHookFile` names the hook module Codeman installs into a local workspace for a CLI whose hooks are files rather than a settings block (omp: `<workspace>/.claude/hooks/pre/codeman-approval.ts`, written by `applyApprovalHookFile()` in hooks-config.ts from both create paths, after the launch pre-flight and only while `workspaceHooksEnabled` is on). The module forwards the CLI's own tool approval events to `/api/hook-event`, and that is all the field grants: `blocked` can fire and a dialog opens an alert-only Approvals Inbox item (`approvalHookAvailableForMode()` in session-wait-registry.ts, which says no for an adopted, remote or Docker session). It is deliberately not a `hooks` value: every reader of `hooks` takes a yes for Claude's full hook set, so `stop` stays a 400 for such a CLI. ⚠️ A bare `.ts` file name and nothing else (schema.ts): `clis.json` can set it, and it is written into the user's workspace. A file of that name without Codeman's marker is the user's own and is left alone, and a symbolic link at any path component is refused.
|
||||||
|
|
||||||
| CLI | Skill directory |
|
| CLI | Skill directory |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| claude | `.claude/skills/codeman` |
|
| claude | `.claude/skills/codeman` |
|
||||||
| codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` |
|
| codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` |
|
||||||
| pi, shell | none |
|
| pi, shell, jcode | none |
|
||||||
|
|
||||||
|
jcode reads `.agents/skills` too, but it is left out (`agentSkillInjection: false`): its tools run in one shared per-user daemon with the CODEMAN_* identity of whichever session started it, so an injected skill would act as that session ([jcode-integration.md](jcode-integration.md#the-shared-daemon)).
|
||||||
|
|
||||||
Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `<workspace>/.agents/skills/` (gemini only in its cwd, dsh only at the git root, else cwd), and Claude Code does not read it, so a case shared with a claude session lists the skill once there (opencode, grok and omp read both directories and see the same skill twice, identical, under one name). ⚠️ pi is left out on purpose: any project-level `.agents/skills` or `.pi/skills` makes pi ask "Trust project folder?" at startup, which would stall a spawned worker on a dialog; it reads the user-level `~/.agents/skills` without the prompt (`npx skills add Ark0N/Codeman --skill codeman -g`). A codex, gemini or other session's copy in a case that pi sessions also use triggers that prompt once for the human there. ⚠️ `agentSkillDir` is a closed enum (`AGENT_SKILL_DIRS`, schema.ts), never a free path: `clis.json` can set it and it decides where the server writes. ⚠️ Two codex traps the skill names rather than fixes: codex's default sandbox blocks network even to loopback (the skill needs **Bypass approvals and sandbox**), and codex 0.162 runs tool commands through a shared `app-server` daemon whose environment (`CODEMAN_SESSION_ID`, `CODEMAN_API_URL`) is that of the session that started it.
|
Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `<workspace>/.agents/skills/` (gemini only in its cwd, dsh only at the git root, else cwd), and Claude Code does not read it, so a case shared with a claude session lists the skill once there (opencode, grok and omp read both directories and see the same skill twice, identical, under one name). ⚠️ pi is left out on purpose: any project-level `.agents/skills` or `.pi/skills` makes pi ask "Trust project folder?" at startup, which would stall a spawned worker on a dialog; it reads the user-level `~/.agents/skills` without the prompt (`npx skills add Ark0N/Codeman --skill codeman -g`). A codex, gemini or other session's copy in a case that pi sessions also use triggers that prompt once for the human there. ⚠️ `agentSkillDir` is a closed enum (`AGENT_SKILL_DIRS`, schema.ts), never a free path: `clis.json` can set it and it decides where the server writes. ⚠️ Two codex traps the skill names rather than fixes: codex's default sandbox blocks network even to loopback (the skill needs **Bypass approvals and sandbox**), and codex 0.162 runs tool commands through a shared `app-server` daemon whose environment (`CODEMAN_SESSION_ID`, `CODEMAN_API_URL`) is that of the session that started it.
|
||||||
|
|
||||||
@@ -235,6 +239,10 @@ Measured 2026-10-11 against the installed builds or their source: every CLI in t
|
|||||||
|
|
||||||
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
|
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
|
||||||
|
|
||||||
|
## A CLI with a shared daemon
|
||||||
|
|
||||||
|
`capabilities.sharedDaemon: true` says the CLI's TUI is a client of one per-user daemon that its first client starts (in its own session and process group) and every later session shares. jcode is the one stock entry that declares it. A session kill normally signals every descendant of the pane by parent pid, and the daemon is still a descendant of the pane that started it, so closing that one tab cut every other session of the CLI off (measured with jcode). With the flag set, `killSession` signals only the descendants still in the pane's own process group (`descendantsInPaneGroup()` in `src/proc-tree.ts`, one bounded `ps -o pid=,pgid=` lookup; a lookup that fails keeps the full kill). `test/proc-walk-bounds.test.ts` pins that jcode alone declares it, so no other CLI's kill changes.
|
||||||
|
|
||||||
## Arg-template safety
|
## Arg-template safety
|
||||||
|
|
||||||
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
|
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
|
||||||
@@ -368,7 +376,7 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
|||||||
|
|
||||||
## MCP server sync
|
## MCP server sync
|
||||||
|
|
||||||
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (and GitHub Copilot CLI, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
|
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (and GitHub Copilot CLI, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek, jcode) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
|
||||||
|
|
||||||
**Tools that are not run modes, or that declare no `mcpConfig`.** `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. GitHub Copilot CLI is both: its registry entry declares `mcpConfig`, so while the run mode is enabled the sync-only row is skipped, and it applies only if the run mode is disabled. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
|
**Tools that are not run modes, or that declare no `mcpConfig`.** `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. GitHub Copilot CLI is both: its registry entry declares `mcpConfig`, so while the run mode is enabled the sync-only row is skipped, and it applies only if the run mode is disabled. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
|
||||||
|
|
||||||
|
|||||||
@@ -578,6 +578,13 @@ automatically). Results:
|
|||||||
|
|
||||||
- **Claude, opencode, Pi, Grok, OMP** — verified: a real "hello world" reply
|
- **Claude, opencode, Pi, Grok, OMP** — verified: a real "hello world" reply
|
||||||
came back through the endpoint.
|
came back through the endpoint.
|
||||||
|
Pi's model entry carries the endpoint's context length when one is known
|
||||||
|
(`contextWindow`, the full window, and `maxTokens`, a quarter of it and
|
||||||
|
8,192 at most). Without it Pi takes an unknown model for a 128,000-token
|
||||||
|
one (its footer reads `/128k`) and asks for 16,384 reply tokens, so on a
|
||||||
|
smaller window it never compacts before the server's real window is full.
|
||||||
|
Measured on pi 1.5.2: with both fields `pi --list-models` shows the window
|
||||||
|
and the request carries `max_tokens: 8192`.
|
||||||
- **Codex** — the config is structurally correct, and against a llama-swap
|
- **Codex** — the config is structurally correct, and against a llama-swap
|
||||||
server that DOES answer `/v1/responses` (confirmed live: a plain,
|
server that DOES answer `/v1/responses` (confirmed live: a plain,
|
||||||
no-tool-call chat turn returned a real reply), the picture is more
|
no-tool-call chat turn returned a real reply), the picture is more
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ must not change what is inside an image tagged `codeman/agent:base`, or two mach
|
|||||||
that tag hold different images and every cache decision downstream is a lie. Each entry's
|
that tag hold different images and every cache decision downstream is a lie. Each entry's
|
||||||
`enabled` flag IS honoured, so a CLI that ships disabled is never baked in.
|
`enabled` flag IS honoured, so a CLI that ships disabled is never baked in.
|
||||||
|
|
||||||
Five CLIs keep hand-written layers, for two different reasons that are easy to conflate.
|
Six CLIs keep hand-written layers, for two different reasons that are easy to conflate.
|
||||||
`antigravity`, `grok` and `omp` declare no `npmPackage` at all, so they never enter the shared
|
`antigravity`, `grok`, `omp` and `jcode` declare no `npmPackage` at all, so they never enter the shared
|
||||||
npm layer and each gets a vendor-installer layer instead. `pi` and `deepseek` ARE on npm but
|
npm layer and each gets a vendor-installer layer instead. `pi` and `deepseek` ARE on npm but
|
||||||
carry `discovery.install.agentImageLayer` in `stock.ts` (a REGISTRY field, rather than an
|
carry `discovery.install.agentImageLayer` in `stock.ts` (a REGISTRY field, rather than an
|
||||||
id-keyed table duplicated between the two producers of the image's build args), which pulls
|
id-keyed table duplicated between the two producers of the image's build args), which pulls
|
||||||
@@ -47,6 +47,7 @@ them out of the shared layer because a plain `npm install -g` is not enough for
|
|||||||
| `deepseek` | Needs `pnpm` alongside it (`dsh plugin`, issue #352) plus a `dsh-tui` profile install. |
|
| `deepseek` | Needs `pnpm` alongside it (`dsh plugin`, issue #352) plus a `dsh-tui` profile install. |
|
||||||
| `antigravity` | Not on npm — Google ships a standalone binary (~190MB, the largest layer). |
|
| `antigravity` | Not on npm — Google ships a standalone binary (~190MB, the largest layer). |
|
||||||
| `grok`, `omp` | Not on npm — standalone vendor installers. |
|
| `grok`, `omp` | Not on npm — standalone vendor installers. |
|
||||||
|
| `jcode` | Not on npm: jcode.sh's installer, then the WHOLE version directory copied to `/opt/jcode` (the launcher is an `sh` script that execs the binary beside it). |
|
||||||
|
|
||||||
`test/docker-agent-image-coverage.test.ts` requires every special case to carry a written
|
`test/docker-agent-image-coverage.test.ts` requires every special case to carry a written
|
||||||
reason AND still be present in the Dockerfile, so an exclusion cannot silently become an
|
reason AND still be present in the Dockerfile, so an exclusion cannot silently become an
|
||||||
@@ -62,7 +63,7 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm codeman/agent:base bash -lc \
|
docker run --rm codeman/agent:base bash -lc \
|
||||||
'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
|
'for c in claude codex gemini opencode agy pi grok dsh omp jcode; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ `dsh --version` is the one line above that answers a different question than the
|
⚠️ `dsh --version` is the one line above that answers a different question than the
|
||||||
@@ -81,6 +82,8 @@ Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (G
|
|||||||
|
|
||||||
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
|
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
|
||||||
|
|
||||||
|
jcode's credentials are not seeded at all: `~/.jcode` keeps its OAuth tokens beside ~160MB of builds, its sessions and SQLite caches, and its API keys live in `~/.config/jcode`, so there is no small set of files to copy. Sign in to jcode inside the container (`docs/jcode-integration.md`).
|
||||||
|
|
||||||
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
|
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
|
||||||
|
|
||||||
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
|
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ target escapes the workspace is already rejected. Ownership is `findSessionOrFai
|
|||||||
- CSS: `src/web/public/styles.css:9320-9430`. Overlay `z-index: 2000`, window `80vw/80vh`, capped
|
- CSS: `src/web/public/styles.css:9320-9430`. Overlay `z-index: 2000`, window `80vw/80vh`, capped
|
||||||
`900x700`. There are **no `.file-preview-*` rules in `mobile.css` at all**.
|
`900x700`. There are **no `.file-preview-*` rules in `mobile.css` at all**.
|
||||||
|
|
||||||
**Reachability on phones.** The header File Viewer button is hidden below 430px
|
**Reachability on phones.** When this plan was written the header File Viewer button was hidden below 430px
|
||||||
(`mobile.css:482`, locked by `KNOWN_PHONE_HIDDEN` in `test/mobile-header-buttons-policy.test.ts`), so on a
|
(`mobile.css:482`, locked by `KNOWN_PHONE_HIDDEN` in `test/mobile-header-buttons-policy.test.ts`); since
|
||||||
|
#759 it shows on a phone once switched on there, and stays off by default. Without it, on a
|
||||||
phone the preview overlay is reached through:
|
phone the preview overlay is reached through:
|
||||||
|
|
||||||
1. an attachment card's **Preview** button (`panels-ui.js:3451`), which is exactly the "agent just wrote a
|
1. an attachment card's **Preview** button (`panels-ui.js:3451`), which is exactly the "agent just wrote a
|
||||||
@@ -109,7 +110,9 @@ Decisions baked in:
|
|||||||
- `svg` and `env` are deliberately marked with `?` above: `svg` is served as an untrusted octet-stream on
|
- `svg` and `env` are deliberately marked with `?` above: `svg` is served as an untrusted octet-stream on
|
||||||
the read side (`file-routes.ts:118`) so allowing an edit is defensible, but I recommend **excluding
|
the read side (`file-routes.ts:118`) so allowing an edit is defensible, but I recommend **excluding
|
||||||
both** in v1. `.env` files are matched by `isSensitivePath()` anyway and would be rejected downstream;
|
both** in v1. `.env` files are matched by `isSensitivePath()` anyway and would be rejected downstream;
|
||||||
excluding them at the allowlist keeps a single obvious refusal.
|
excluding them at the allowlist keeps a single obvious refusal. Superseded for dotenv: the plain
|
||||||
|
preview serves a workspace `.env` in full, so the editor now opens dotenv files (`allowDotenv`, see
|
||||||
|
`docs/architecture-invariants.md#file-viewer-edit-mode`); `svg` stays excluded.
|
||||||
- `isDeniedEditRelativePath` blocks the `.git/` subtree: `.git/hooks/*` is code execution and a corrupt
|
- `isDeniedEditRelativePath` blocks the `.git/` subtree: `.git/hooks/*` is code execution and a corrupt
|
||||||
index is unrecoverable-looking to a user who only wanted to fix a typo. Other dotfiles stay allowed but
|
index is unrecoverable-looking to a user who only wanted to fix a typo. Other dotfiles stay allowed but
|
||||||
are not reachable from the tree UI anyway (`showHidden=false`).
|
are not reachable from the tree UI anyway (`showHidden=false`).
|
||||||
@@ -363,7 +366,8 @@ Route cases to assert explicitly:
|
|||||||
4. symlink pointing inside is written through to the target
|
4. symlink pointing inside is written through to the target
|
||||||
5. non-allowlisted extension gives 400
|
5. non-allowlisted extension gives 400
|
||||||
6. `.git/config` gives 403
|
6. `.git/config` gives 403
|
||||||
7. a `.env` in the workspace gives 403 (sensitive-path)
|
7. a `.env` in the workspace gives 403 (sensitive-path); superseded: it is now editable, while `.ssh/.env`,
|
||||||
|
the data dir's `.env` and the other credential files still give 403
|
||||||
8. a file with a NUL byte gives 400
|
8. a file with a NUL byte gives 400
|
||||||
9. a latin-1 file that fails the UTF-8 round-trip gives 400
|
9. a latin-1 file that fails the UTF-8 round-trip gives 400
|
||||||
10. stale `baseHash` gives 409 (`CONFLICT` envelope, no data); `force:true` then succeeds
|
10. stale `baseHash` gives 409 (`CONFLICT` envelope, no data); `force:true` then succeeds
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# jcode sessions
|
||||||
|
|
||||||
|
Codeman can drive [jcode](https://github.com/1jehuang/jcode) (1jehuang/jcode, MIT, a
|
||||||
|
coding-agent TUI written in Rust) as a session backend, alongside Claude Code, OpenCode,
|
||||||
|
Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP and GitHub Copilot. `jcode` is its own **run mode**:
|
||||||
|
its own PTY, its own tmux session, its own tab identity (amber, `JC` badge, a plain ring
|
||||||
|
standing in for jcode's halftone donut). It is not a location overlay like Docker or
|
||||||
|
remote-SSH cases, and it is not a web tab.
|
||||||
|
|
||||||
|
Everything here was measured against jcode v0.98.1 on 2026-10-16 (see
|
||||||
|
[How it was measured](#how-it-was-measured)). The registry entry in
|
||||||
|
`src/config/cli-registry/stock.ts` carries the same facts in its comments and is the
|
||||||
|
source of truth when the two disagree.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://jcode.sh/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer downloads a checksummed release binary into
|
||||||
|
`~/.jcode/builds/versions/<version>/` and links `~/.local/bin/jcode` (its
|
||||||
|
`JCODE_INSTALL_DIR` default) to it. The linked `jcode` is a small `sh` **launcher** that
|
||||||
|
resolves its own path and execs the `jcode-<os>-<arch>.bin` beside it, so the version
|
||||||
|
directory is what matters, not the single file the link points at.
|
||||||
|
|
||||||
|
Codeman resolves the binary through the generic registry resolver (there is no
|
||||||
|
jcode-specific resolver module): the server PATH, then `~/.local/bin`, `/usr/local/bin`
|
||||||
|
and `~/bin`, then an interactive login shell. npm carries an unrelated package also called
|
||||||
|
`jcode`, so a PATH hit is not trusted on its own: `jcode --version` must print
|
||||||
|
`jcode v<semver>` (the real one prints `jcode v0.98.1 (1c7426eb1)`), and anything else
|
||||||
|
counts as absent. Check what it resolved:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s localhost:3000/api/jcode/status | jq
|
||||||
|
# { "available": true, "path": "/home/you/.local/bin", "version": "0.98.1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authenticate
|
||||||
|
|
||||||
|
jcode keeps its own logins: OAuth accounts in `~/.jcode/auth.json`, API keys in
|
||||||
|
`~/.config/jcode/<provider>.env`, the default provider and model in `~/.jcode/config.toml`.
|
||||||
|
Set them up with jcode itself (`jcode login`, `/login` inside the TUI, or
|
||||||
|
`jcode provider add <name> --base-url ... --model ...` for an OpenAI-compatible server).
|
||||||
|
jcode can also offer to reuse logins other CLIs left behind; it asks before reading any of
|
||||||
|
them. Codeman forwards no credentials into a jcode pane.
|
||||||
|
|
||||||
|
## What Codeman wires up
|
||||||
|
|
||||||
|
`JcodeConfig` (per session, persisted in `state.json`, round-trips through respawn and
|
||||||
|
server restart):
|
||||||
|
|
||||||
|
| Field | Flag | Notes |
|
||||||
|
| ----------------- | --------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| `model` | `--model <v>` | e.g. `claude-opus-4-8`, `gpt-5.5` |
|
||||||
|
| `resumeSessionId` | `--resume <v>` | a session id (`session_fox_1792160401272_f5b47ce47d631006`) or its short name (`fox`) |
|
||||||
|
|
||||||
|
Both values are regex-validated at the route and checked again by the argv engine, and a
|
||||||
|
value that fails is **dropped**, never escaped into the command. `resumeSessionId` uses the
|
||||||
|
`path-segment` pattern (a leading letter or digit) on purpose: jcode's `--resume` takes an
|
||||||
|
OPTIONAL value (a bare `--resume` opens its session picker), so a value starting with `-`
|
||||||
|
would be parsed as a flag of its own (`--resume --no-update` is two flags).
|
||||||
|
|
||||||
|
The Run button sends **no** `jcodeConfig`: jcode has no approval prompts and no bypass
|
||||||
|
flag, so there is nothing to opt into (the same shape as pi). A bare spawn is just `jcode`.
|
||||||
|
|
||||||
|
There is **no** `envOverrides` prefix for jcode: `JCODE_*` is not allowlisted. See
|
||||||
|
[The shared daemon](#the-shared-daemon) for why.
|
||||||
|
|
||||||
|
## The shared daemon
|
||||||
|
|
||||||
|
Every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`). The first client
|
||||||
|
starts it, in its own session and process group (`setsid`), and every later client attaches
|
||||||
|
to it. It stays the first client's CHILD by parent pid, though, which is what the
|
||||||
|
[session kill](#closing-a-tab-never-takes-the-daemon-down) has to account for. The agent and its tools run in the DAEMON, with the daemon's
|
||||||
|
environment. Measured: a second client started with `CODEMAN_PROBE=second-client` asked its
|
||||||
|
bash tool to `echo "probe=[$CODEMAN_PROBE]"` and got `probe=[]`.
|
||||||
|
|
||||||
|
Three things follow, and each one is a deliberate decision:
|
||||||
|
|
||||||
|
- **The agent skill is not injected.** jcode does read `./.agents/skills` (and
|
||||||
|
`./.jcode/skills`, `./.claude/skills`), but a jcode agent's tools see the
|
||||||
|
`CODEMAN_SESSION_ID`, `CODEMAN_API_URL` and `CODEMAN_MUX` of whichever session started
|
||||||
|
the daemon, or none at all if it was started outside Codeman. An injected skill would act
|
||||||
|
as THAT session: spawn children under the wrong parent, prompt another session's
|
||||||
|
workers. A user-level copy of the skill (`~/.agents/skills`, which jcode also reads) has
|
||||||
|
the same problem; this is the same trap as codex's shared `app-server`, only it is jcode's
|
||||||
|
normal case rather than an edge.
|
||||||
|
- **No `JCODE_*` envOverrides prefix.** Most of that namespace is read by the daemon, so a
|
||||||
|
per-session value would do nothing, and where it did act (in the session that happens to
|
||||||
|
start the daemon) it would reach every jcode session on the machine. The namespace
|
||||||
|
includes variables that run code or redirect traffic: `JCODE_HOOK_*`, `JCODE_SPAWN_HOOK`,
|
||||||
|
`JCODE_*_COMMAND`, `JCODE_*_API_BASE`. Configure jcode in `~/.jcode/config.toml`, or in
|
||||||
|
the Codeman server's own environment.
|
||||||
|
- **Deleting a Codeman jcode session leaves the daemon running**, by design: the other
|
||||||
|
sessions live there (next section). `jcode server stop --force` stops it by hand.
|
||||||
|
|
||||||
|
### Closing a tab never takes the daemon down
|
||||||
|
|
||||||
|
Codeman's session kill signals every descendant of the pane (by parent pid), so it used to
|
||||||
|
kill the shared daemon whenever the closed tab happened to be the one that started it.
|
||||||
|
Measured on a beta before the fix: two jcode sessions, the first one deleted, and the second
|
||||||
|
one's client looped on `Connection lost - retrying (attempt 9) - server closed the
|
||||||
|
connection` with its prompt queued (`⏳`), while a send-and-wait on it timed out.
|
||||||
|
|
||||||
|
The jcode entry declares `capabilities.sharedDaemon: true`, and for such a CLI the kill
|
||||||
|
signals only the descendants still in the pane's own process group
|
||||||
|
(`descendantsInPaneGroup()`, `src/proc-tree.ts`, one bounded `ps -o pid=,pgid=`). The daemon
|
||||||
|
left that group when it called `setsid`, and everything it runs inherits its group, so the
|
||||||
|
daemon and its tools are spared while the client and anything else in the pane's group are
|
||||||
|
killed as before. A lookup that fails keeps the old behaviour (kill every descendant). Every
|
||||||
|
other CLI is unaffected. Measured after the fix: the kill logs `Sparing 1 process(es)
|
||||||
|
outside pane <pid>'s group (the CLI's shared daemon)`, the daemon keeps its pid, and the
|
||||||
|
remaining session answered a send-and-wait in 5 s.
|
||||||
|
|
||||||
|
The flip side: a process a jcode agent started (a dev server, a watcher) is a child of the
|
||||||
|
daemon, not of the pane, so closing the tab does not stop it. That is how jcode behaves in a
|
||||||
|
plain terminal too.
|
||||||
|
|
||||||
|
## Approvals and multi-user mode
|
||||||
|
|
||||||
|
jcode has no approval dialog. Its bash tool runs ungated apart from jcode's own model-side
|
||||||
|
reflection gate on risky commands and a hard deny on catastrophic paths. So there is no
|
||||||
|
bypass flag to clamp, nothing for the multi-user clamp to force off, and no dialog for
|
||||||
|
Codeman to read as `blocked`. In multi-user mode a jcode session is like a pi session: an
|
||||||
|
agent that runs tools without asking, under the one OS account every session shares.
|
||||||
|
|
||||||
|
## Terminal behavior
|
||||||
|
|
||||||
|
jcode is a **full-screen alternate-screen TUI with mouse tracking on** (tmux
|
||||||
|
`alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`). It takes the opencode strip,
|
||||||
|
`altScreen: 'strip-mux-and-mouse'`: the mouse DECSETs are stripped, so a drag selects text
|
||||||
|
locally (Auto Copy and copy-on-select work) instead of being reported to jcode.
|
||||||
|
|
||||||
|
Scrolling: jcode pages its own transcript on PageUp/PageDown and also scrolls on SGR wheel
|
||||||
|
reports (both measured). It declares `transcriptPageKeys: true`, so over the hollow
|
||||||
|
alt-screen buffer a wheel or a swipe becomes PageUp/PageDown instead of doing nothing.
|
||||||
|
|
||||||
|
Input: a long prompt sent the way Codeman sends one (a bracketed paste, then a separate
|
||||||
|
Enter) submits cleanly (measured with a 170-character prompt).
|
||||||
|
|
||||||
|
### How Codeman reads a working pane
|
||||||
|
|
||||||
|
At rest the composer row is `<n>> ` (the prompt number, then `>`); a submitted prompt is
|
||||||
|
echoed into the transcript as `<n>› <text>`. While a turn runs the composer row reads
|
||||||
|
`<n>…` and a status row appears directly above it: a braille spinner (the gemini/omp frame
|
||||||
|
set, about 12 fps), then the phase and the elapsed time:
|
||||||
|
|
||||||
|
```
|
||||||
|
⠋ sending… 0s
|
||||||
|
⠦ thinking… 2s · https
|
||||||
|
⠼ waiting for response… 3s · https
|
||||||
|
⠸ running bash · <detail> · 12s · Alt+B bg
|
||||||
|
⠼ 12s · 45.1 tps · ↑16k ↓20 (streaming: the time comes first)
|
||||||
|
↻ network disconnected, waiting to retry · … · 5s
|
||||||
|
```
|
||||||
|
|
||||||
|
Past a minute the time reads `1m 3s`, past an hour `1h 2m`. At rest that row is empty or a
|
||||||
|
dim tip, never a spinner, and the turn leaves a summary row (` 21s · 199.6 tps · ↑17k ↓2k`)
|
||||||
|
that has a time but no spinner. The `workingLine` pattern therefore needs a spinner (or the
|
||||||
|
`↻ network disconnected` row) at the start of a row AND an elapsed time on it.
|
||||||
|
|
||||||
|
The composer glyph is the bare `>`. tmux redraws only the cells that changed, and on the
|
||||||
|
composer row at the end of a turn that is the `…` turning into `>` (the recorded client
|
||||||
|
stream carries `\x1b[43;2H...> `), never the prompt number, so `>` is what reliably arrives.
|
||||||
|
Mid-turn stream chunks never contain it. The glyph only ARMS the idle check; the screen and
|
||||||
|
the silence decide.
|
||||||
|
|
||||||
|
Silence is a sound signal for jcode: the spinner repaints the pane at least every 100 ms for
|
||||||
|
the whole turn, and at rest jcode sends nothing at all (12 seconds recorded: zero bytes).
|
||||||
|
|
||||||
|
The submit verifier stays inert for jcode, which is what it wants (it has no early-Enter
|
||||||
|
defect): every composer row starts with its prompt number, so no screen row starts with
|
||||||
|
`>` the way the verifier requires.
|
||||||
|
|
||||||
|
### Reading a jcode worker's answer
|
||||||
|
|
||||||
|
`GET /api/sessions/:id/last-response` returns an **honest empty** (`text: ""`, and
|
||||||
|
`messages: []` with `?context=full`) for jcode. The pane segmenter the other TUIs use cannot
|
||||||
|
read a repainting alternate-screen stream (measured: it served jcode's welcome banner as
|
||||||
|
"the answer"), and without this the route would fall through to the Claude transcript scan,
|
||||||
|
which could adopt a Claude conversation from the same folder. Read a jcode worker's screen
|
||||||
|
instead:
|
||||||
|
|
||||||
|
- `codeman agent read <id> --tail 3000`
|
||||||
|
- MCP `read_output` with `source: "terminal"`
|
||||||
|
- `GET /api/sessions/:id/terminal?tail=3000`
|
||||||
|
|
||||||
|
There are no `stop`/`blocked` hook signals for jcode: wait for `idle`, or use `wait-output`
|
||||||
|
markers, as with every hook-less CLI.
|
||||||
|
|
||||||
|
## First run: the onboarding wizard
|
||||||
|
|
||||||
|
A fresh jcode install opens an onboarding wizard ("Welcome to jcode onboarding / Log in to
|
||||||
|
OpenAI?") instead of the composer, on every launch, until its launch counter passes 5 or
|
||||||
|
10 sessions exist. A prompt typed into the wizard is lost (measured: it wedged that client).
|
||||||
|
Before driving jcode from Codeman, run `jcode` once in a terminal and finish or skip the
|
||||||
|
wizard, or press Esc in the Codeman pane.
|
||||||
|
|
||||||
|
## jcode's own defaults Codeman does not change
|
||||||
|
|
||||||
|
- **Telemetry** is on by default in jcode (install count, version, OS, session activity,
|
||||||
|
tool counts; jcode's `TELEMETRY.md`). Opt out with `jcode telemetry disable`, or set
|
||||||
|
`JCODE_NO_TELEMETRY=1` in the Codeman SERVER's environment (it is not a per-session env
|
||||||
|
override, see above).
|
||||||
|
- **Auto-update** is on by default for release builds; `--no-update` is jcode's own flag and
|
||||||
|
Codeman does not pass it.
|
||||||
|
- **Self-dev mode**: jcode switches into it when started inside a checkout of jcode itself.
|
||||||
|
|
||||||
|
## Docker cases
|
||||||
|
|
||||||
|
jcode is not on npm, so the agent image installs it in a dedicated Dockerfile step through
|
||||||
|
jcode.sh's installer, then copies the WHOLE version directory to `/opt/jcode` (the launcher
|
||||||
|
execs the binary beside it, so copying only the linked file, as the grok and omp steps do,
|
||||||
|
would leave a launcher with nothing to run) and links `/usr/local/bin/jcode` to the launcher.
|
||||||
|
Root's `~/.jcode` is dropped in the same layer so the image does not carry the ~160 MB
|
||||||
|
binary twice. Verified in `node:22-bookworm-slim`, including `jcode --version` as the
|
||||||
|
unprivileged `agent` user. Rebuild with the mandatory `--no-cache`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/build-agent-image.mjs --no-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
No credentials are seeded into the container: `~/.jcode` holds the OAuth tokens beside the
|
||||||
|
builds, sessions and SQLite caches, and API keys live in `~/.config/jcode`. Sign in inside
|
||||||
|
the container instead.
|
||||||
|
|
||||||
|
## Remote SSH cases
|
||||||
|
|
||||||
|
`jcode` mode is routed through an interactive login shell
|
||||||
|
(`exec "$SHELL" -i -l -c 'jcode'`), because sshd's remote-command PATH does not include
|
||||||
|
`~/.local/bin`. Per-session config does not cross ssh and is rejected rather than silently
|
||||||
|
ignored; a host whose `jcode` lives elsewhere takes the per-host `commands.jcode` override.
|
||||||
|
|
||||||
|
## Not supported yet, and why
|
||||||
|
|
||||||
|
- **A transcript reader** (a real `last-response`, Response Viewer content). jcode writes
|
||||||
|
sessions as `~/.jcode/sessions/session_<name>_<ms>_<hex>.json` plus a `.journal.jsonl`,
|
||||||
|
which is easy to parse, but pairing a Codeman pane with ITS session file is ambiguous: the
|
||||||
|
session JSON's `last_pid` is the shared daemon's, not the pane's, so two jcode panes in
|
||||||
|
one folder cannot be told apart from the files alone.
|
||||||
|
- **Custom Model Endpoint Profiles** (`customModelInjection: unsupported`). jcode's provider
|
||||||
|
is resolved in the daemon, not the pane, so a per-session redirect has nothing reliable
|
||||||
|
to land on.
|
||||||
|
- **The model in tile headers** (`modelDetect`). The status bar names provider and model
|
||||||
|
(`qwen5090 qwen3.8 27b`), but its layout varies by provider and width and was not
|
||||||
|
measured enough to anchor a pattern on. A session shows its launch model, if any.
|
||||||
|
- **Hook-based `stop`/`blocked` signals.** jcode has lifecycle hooks (`session_start`,
|
||||||
|
`turn_start`, `turn_end`, `session_end`), but they run in the daemon too, with the
|
||||||
|
daemon's environment, so they cannot tell Codeman which session they belong to.
|
||||||
|
- **Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe are off**
|
||||||
|
for jcode, as for every external CLI.
|
||||||
|
|
||||||
|
A per-session daemon (`jcode --socket <path>`) would give each pane its own environment,
|
||||||
|
but it gives up the shared daemon that is jcode's main design point (cross-session swarm
|
||||||
|
awareness, a few MB per extra session), so it was not done here.
|
||||||
|
|
||||||
|
## How it was measured
|
||||||
|
|
||||||
|
- jcode installed with its own installer into a scratch `HOME` with telemetry off
|
||||||
|
(`DO_NOT_TRACK=1`, `JCODE_NO_TELEMETRY=1`) and a short `XDG_RUNTIME_DIR` (the daemon's
|
||||||
|
Unix socket path must stay under the `SUN_LEN` limit), so nothing touched the real home.
|
||||||
|
- A provider set up with `jcode provider add` pointing at a local OpenAI-compatible server
|
||||||
|
(an RTX 5090 serving Qwen 3.8 27B), so no subscription login was involved.
|
||||||
|
- jcode run in a scratch tmux socket at 160x45; `tmux capture-pane -p` every 200 ms through
|
||||||
|
real turns (a plain reply, a bash tool call, a streamed answer); `pipe-pane` at rest; and a
|
||||||
|
tmux CLIENT attached through a PTY to record exactly what Codeman's PTY reads.
|
||||||
|
- PageUp/PageDown sent with `send-keys`; SGR wheel reports (`\x1b[<64;40;10M`) pasted raw.
|
||||||
|
- A second client with its own environment to prove where tools run.
|
||||||
|
- The Docker step run in a throwaway `node:22-bookworm-slim` container.
|
||||||
|
- End to end through an isolated beta Codeman built from this branch (its own
|
||||||
|
`CODEMAN_INSTANCE`, port and tmux socket): `POST /api/quick-start` with `mode: "jcode"`,
|
||||||
|
`/api/jcode/status`, send-and-wait (`wait: "idle"` returned after the turn, 12.7 s for a
|
||||||
|
turn with a 5 s tool call, not during it), two sessions with the first deleted, and a
|
||||||
|
Playwright pass over the Run menu entry, the `Run JC` button and the rendered transcript.
|
||||||
|
|
||||||
|
The fixtures in `test/jcode-mode.test.ts` are verbatim from these captures.
|
||||||
+198
-42
@@ -4,7 +4,9 @@ Codeman can set up a coding model that runs on the Mac the server runs on: no ac
|
|||||||
no API key, and the session's prompts and files go to a model on this Mac rather than a
|
no API key, and the session's prompts and files go to a model on this Mac rather than a
|
||||||
cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that
|
cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that
|
||||||
shows what fits this Mac, installs the choice and ends on a coding agent session that
|
shows what fits this Mac, installs the choice and ends on a coding agent session that
|
||||||
runs on it.
|
runs on it. The same window then lists the models that are on this Mac with what each takes
|
||||||
|
on disk, adds another one, switches between them and deletes one
|
||||||
|
([The models on this Mac](#the-models-on-this-mac)).
|
||||||
|
|
||||||
**Experimental.** The routes and their payloads may change in any release
|
**Experimental.** The routes and their payloads may change in any release
|
||||||
([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple
|
([versioning-policy.md](versioning-policy.md), "Experimental local model routes"). Apple
|
||||||
@@ -17,28 +19,29 @@ in [architecture-invariants.md](architecture-invariants.md#local-model-install).
|
|||||||
## Which model fits which Mac
|
## Which model fits which Mac
|
||||||
|
|
||||||
The window offers the best model of the catalogue (`src/config/local-model-catalog.ts`) that
|
The window offers the best model of the catalogue (`src/config/local-model-catalog.ts`) that
|
||||||
fits this Mac's memory at a context a coding agent can work with, and up to four more. What
|
fits this Mac's memory at a context a coding agent can work with, and under it every other
|
||||||
the catalogue answers today, before the runtime measured anything (the budget is then an
|
model of the catalogue that fits (eleven models today: a 16 GB Mac sees one of them, a 512 GB
|
||||||
estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and never more than the
|
Mac all eleven). What the catalogue answers today, before the runtime measured anything (the
|
||||||
memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
|
budget is then an estimate: 2/3 of the memory below 36 GB, 3/4 from 36 GB, less 1 GiB, and
|
||||||
|
never more than the memory less 6, 8 or 12 GiB for macOS, Codeman, a browser and the agent):
|
||||||
|
|
||||||
| Memory | Budget | What it gets |
|
| Memory | Budget | What it gets |
|
||||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 10.2 GB at 65,536 tokens |
|
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 9.3 GB at 65,536 tokens |
|
||||||
| 16 GB | 10.4 GB | Nothing. MiMo V2.6 9B at 65,536 tokens needs 10.2 GB, 98.7% of the budget, just past the 98% a tight fit may reach (it is 95% of the 10.7 GB a 16 GB Mac mini measured). On the edge either way: only a run on such a Mac settles it |
|
| 16 GB | 10.4 GB | A small model (`limited`): Ornith 1.0 9B at 65,536 tokens, 9.3 GB, tight (90%). Its build has no vision projector, which makes it 0.9 GB smaller than the other two 9B builds: MiMo V2.6 9B needs 10.2 GB there, 98.7% of the budget, just past the 98% a tight fit may reach. Once the runtime measures what a 16 GB Mac mini measured (10.7 GB), all three fit and MiMo is the pick. No 9B was run on such a Mac by us: only a run settles it |
|
||||||
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Its base, Qwen3.5 9B, is the one alternative |
|
| 18 GB | 11.8 GB | A small model (`limited`): MiMo V2.6 9B at 131,072 tokens, 11.4 GB, tight. Ornith 1.0 9B (10.5 GB) and Qwen3.5 9B are the alternatives |
|
||||||
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
|
| 24 GB | 16.1 GB | The same, with room. On a base M2 or M3 (100 GB/s) it computes to 9.3 tokens per second: slow |
|
||||||
| 32 GB | 21.8 GB | The same. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip |
|
| 32 GB | 21.8 GB | No longer a small-model Mac: Muse Glimmer 30B fits at 65,536 tokens (21.2 GB, 97%, tight). It is a dense model. On an M1 Max or M2 Max it computes to 11.8 tokens per second and is the pick; on a base or Pro chip it computes to under 6 and is listed as slow, and the pick stays MiMo V2.6 9B at 131,072 tokens with the note that it is a small model. North Mini Code 1.0 needs 23.2 GB at 65,536 tokens; once the runtime measures about 3/4 of the memory, as it did on a 16 and a 48 GB Mac, the Mac gets it at 131,072 tokens, or Qwen3.8 27B at 65,536 tokens on a Max chip. Run once on a Mac (an M5 Pro with 48 GB, 2026-11-12): this GGUF build wrote 15 tokens per second, and an OpenCode worker given a one-line task showed no answer after 7 minutes. Its MLX build, which finished an OpenCode turn on a one-line prompt in 45 seconds, needs 36.1 GB and is offered from 48 GB |
|
||||||
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight |
|
| 36 GB | 27.9 GB | Qwen3.8 27B at 131,072 tokens on an M4 or M5 Max (27.0 GB, 97%); on a slower chip, where the dense 27B computes to under 10 tokens per second, Qwen3.6 35B-A3B as a GGUF build (27.1 GB, 97%). Both tight. Muse Glimmer 30B at 131,072 tokens (21.6 GB, 78%) is the alternative with room to spare |
|
||||||
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). The test was a short task: in a long session MLX may keep up to 8 GiB more, which brings this pair to the edge of the budget, and past it while a long prompt is processed |
|
| 48 GB | 37.6 GB | Qwen3.6 35B-A3B (MLX) at 131,072 tokens: the pair we tested, "Recommended" on an Apple M5 Pro with 48 GB. On a Max chip Qwen3.8 27B at 262,144 tokens (32.1 GB, tight). A session of many prompts on that Mac (a 95,000-token prompt, then twelve separate ones of 15,000 tokens, measured on 2026-11-12) ended at 33.4 GB of the 40.2 GB its runtime measures, still growing by 0.5 GiB a prompt: MLX may keep up to 8 GiB of earlier prompts, which brings this pair to the edge of the budget. Muse Glimmer 30B as its MLX build is offered here too, at 65,536 tokens (36.1 GB, tight) |
|
||||||
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
|
| 64 GB | 50.5 GB | Qwen3.8 27B at 262,144 tokens on a Max chip; Qwen3.6 35B-A3B (MLX) on a Pro chip |
|
||||||
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
|
| 96 GB | 76.2 GB | Qwen3.8 27B at 262,144 tokens; on an M3 Max, Qwen3.6 35B-A3B (MLX) |
|
||||||
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
|
| 128 GB | 102 GB | Ornith 1.5 122B-A10B (MLX) at 131,072 tokens, 97.4 GB, tight |
|
||||||
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
|
| 192, 256, 512 GB | 154 GB+ | Ornith 1.5 122B-A10B at 262,144 tokens |
|
||||||
|
|
||||||
"Tight" means the need is between 85% and 98% of the budget; above 98% a model is not
|
"Tight" means the need is between 85% and 98% of the budget; above 98% a model is not
|
||||||
offered. A model that computes to under 10 tokens per second on the chip is "slow" and ranks
|
offered. A model that computes to under 10 tokens per second on the chip is "slow" and ranks
|
||||||
after the fast ones, which is why the chip changes the pick at 36 to 96 GB. Every model is
|
after the fast ones, which is why the chip changes the pick at 32 to 96 GB. Every model is
|
||||||
offered at the largest context it fits at, and never below 65,536 tokens: OpenCode's own
|
offered at the largest context it fits at, and never below 65,536 tokens: OpenCode's own
|
||||||
prompt is 8,000 to 10,000 tokens, and at 32,768 it would compact after less than one default
|
prompt is 8,000 to 10,000 tokens, and at 32,768 it would compact after less than one default
|
||||||
file read. `test/local-model-recommend.test.ts` ("every memory size") pins this table.
|
file read. `test/local-model-recommend.test.ts` ("every memory size") pins this table.
|
||||||
@@ -70,15 +73,21 @@ names its runner (`runner`) and its figures follow that runner's rule:
|
|||||||
- **MLX** grows its cache with the tokens actually used, keeps snapshots of the recurrent
|
- **MLX** grows its cache with the tokens actually used, keeps snapshots of the recurrent
|
||||||
state, and may keep up to 8 GiB of snapshots of earlier prompts, which no setting bounds. A
|
state, and may keep up to 8 GiB of snapshots of earlier prompts, which no setting bounds. A
|
||||||
Mac always takes the MLX build of a tag that has one. Below 48 GB that 8 GiB does not fit
|
Mac always takes the MLX build of a tag that has one. Below 48 GB that 8 GiB does not fit
|
||||||
beside a useful model, so the catalogue lists GGUF builds there.
|
beside a useful model, so the catalogue lists GGUF builds there. For Muse Glimmer the MLX
|
||||||
|
runner was measured holding 38,000 to 45,000 bytes per token, against the 7,072 its GGUF
|
||||||
|
build computes to: close to every layer's cache at full length, so it evidently does not
|
||||||
|
trim the sliding-window layers the way llama.cpp does.
|
||||||
|
|
||||||
Where every figure comes from:
|
Where every figure comes from:
|
||||||
|
|
||||||
| Model | Runner | Memory figures | Speed (tokens per second per 100 GB/s) |
|
| Model | Runner | Memory figures | Speed (tokens per second per 100 GB/s) |
|
||||||
| ---------------------------- | --------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
| ---------------------------- | --------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
| `qwen3.5:9b-q4_k_m` | llama.cpp | computed from the Qwen3.5-9B config | 9.3: a published run on an M1 Max |
|
| `qwen3.5:9b-q4_k_m` | llama.cpp | computed from the Qwen3.5-9B config | 9.3: a published run on an M1 Max |
|
||||||
|
| `ornith:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B; its config and its GGUF header say so) | 9.3: the same architecture and weights size |
|
||||||
| `mimo-v2.6:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B) | 9.3: the same architecture and size |
|
| `mimo-v2.6:9b-q4_k_m` | llama.cpp | the same (a fine-tune of Qwen3.5-9B) | 9.3: the same architecture and size |
|
||||||
| `north-mini-code-1.0:q4_k_m` | llama.cpp | computed from its config | 13.6: the 35B-A3B's, the same 3 billion active parameters |
|
| `north-mini-code-1.0:q4_k_m` | llama.cpp | computed from its config | 13.6: the 35B-A3B's, the same 3 billion active parameters |
|
||||||
|
| `muse-glimmer:30b-q4_k_m` | llama.cpp | computed from its config: 13 full-attention and 39 sliding-window layers | 2.95: the dense 27B's, whose weights are the same size |
|
||||||
|
| `muse-glimmer:30b-mlx` | MLX | **measured** on an Apple M5 Pro with 48 GB, the 8 GiB of prefix snapshots counted | 5.4: measured beside the 35B-A3B on MLX, 0.32 of its 16.9 |
|
||||||
| `qwen3.6:35b-a3b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.6-35B-A3B config, prediction layer included | 13.6: a published run on an M1 Max |
|
| `qwen3.6:35b-a3b-mtp-q4_k_m` | llama.cpp | computed from the Qwen3.6-35B-A3B config, prediction layer included | 13.6: a published run on an M1 Max |
|
||||||
| `qwen3.6:35b-a3b` | MLX | **measured** on an Apple M5 Pro with 48 GB | 16.9: a published run; 67.7 measured on the tested Mac |
|
| `qwen3.6:35b-a3b` | MLX | **measured** on an Apple M5 Pro with 48 GB | 16.9: a published run; 67.7 measured on the tested Mac |
|
||||||
| `qwen3.8:27b-q8_0` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 1.7: the row below scaled by file size |
|
| `qwen3.8:27b-q8_0` | llama.cpp | computed from the Qwen3.8-27B config, prediction layer included | 1.7: the row below scaled by file size |
|
||||||
@@ -86,13 +95,22 @@ Where every figure comes from:
|
|||||||
| `ornith-1.5:122b` | MLX | computed from the Qwen3.5-122B-A10B config, scaled by the measured MLX row, + 8 GiB | 5.0: the 35B-A3B's, x 3/10 active parameters |
|
| `ornith-1.5:122b` | MLX | computed from the Qwen3.5-122B-A10B config, scaled by the measured MLX row, + 8 GiB | 5.0: the 35B-A3B's, x 3/10 active parameters |
|
||||||
|
|
||||||
Sizes are what the registry serves a Mac for each tag (every layer plus the config), read on
|
Sizes are what the registry serves a Mac for each tag (every layer plus the config), read on
|
||||||
2026-11-08. Only one row was ever run on a Mac, so every other answer in the table above is a
|
2026-11-08 (the Ornith 1.0 and Muse Glimmer rows on 2026-11-10, Muse Glimmer's MLX build on
|
||||||
calculation. For a llama.cpp build the check after the download catches a load that was
|
2026-11-12). Two rows carry memory figures measured on a Mac, both on an Apple M5 Pro with
|
||||||
|
48 GB; every other answer in the table above is a calculation. Three more rows were installed
|
||||||
|
and loaded on that Mac on 2026-11-12 (Qwen3.5 9B, Ornith 1.0 9B and Muse Glimmer as a GGUF
|
||||||
|
build): that checked their download size and that they load on the GPU, not their memory
|
||||||
|
figures. For a llama.cpp build the check after the download catches a load that was
|
||||||
judged too kindly, but not what grows after it in host memory (the checkpoints and the prompt
|
judged too kindly, but not what grows after it in host memory (the checkpoints and the prompt
|
||||||
cache, which are counted, not checked); for an MLX build it catches neither (see "The check"
|
cache, which are counted, not checked); for an MLX build it catches neither (see "The check"
|
||||||
below). The measured row's figures come from single long prompts, not a session of many
|
below). The 35B-A3B's figures come from single long prompts, not a session of many
|
||||||
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left out on
|
requests, in which MLX may keep up to 8 GiB more: such a session was measured on it later
|
||||||
purpose (no evidence of working tool calls, a restrictive licence, a known bug) and why.
|
(the 48 GB row above) and its figures were left as they are. Muse Glimmer's MLX row counts
|
||||||
|
the 8 GiB. The catalogue header lists the models left
|
||||||
|
out on purpose (no evidence of working tool calls, a restrictive licence, a known bug, a
|
||||||
|
build that needs a newer runtime than the pinned one) and why. The strongest of those today
|
||||||
|
is Ripple 65B Preview, published on 2026-11-09: every build of it requires Ollama 0.40.16,
|
||||||
|
one above the pin.
|
||||||
|
|
||||||
A new row needs its model's architecture (attention layers, K/V heads, head size, and its
|
A new row needs its model's architecture (attention layers, K/V heads, head size, and its
|
||||||
linear-attention or sliding-window layers, from its `config.json`) in the table of
|
linear-attention or sliding-window layers, from its `config.json`) in the table of
|
||||||
@@ -103,7 +121,9 @@ for a row it does not know. Its answer for each memory size is then pinned in
|
|||||||
## What an install does, in order
|
## What an install does, in order
|
||||||
|
|
||||||
The person picks a model, presses "Install on this Mac" and confirms the sizes once. The
|
The person picks a model, presses "Install on this Mac" and confirms the sizes once. The
|
||||||
server then runs one job of five steps. The window polls `GET /api/local-model` once a
|
server then runs one job of five steps. The same job adds a second model to an installed one
|
||||||
|
("Add a model"): the runtime is there already, so it downloads the model alone, and the
|
||||||
|
earlier model stays on disk. The window polls `GET /api/local-model` once a
|
||||||
second; the job runs in the server, so the window can be closed.
|
second; the job runs in the server, so the window can be closed.
|
||||||
|
|
||||||
| Step | What it does |
|
| Step | What it does |
|
||||||
@@ -206,6 +226,66 @@ This step does not need the `cliManagementEnabled` setting. That setting gates t
|
|||||||
CLI management surface; this path can only ever run one vetted command for one catalogue
|
CLI management surface; this path can only ever run one vetted command for one catalogue
|
||||||
row.
|
row.
|
||||||
|
|
||||||
|
### Which coding agents, and what each was measured with
|
||||||
|
|
||||||
|
The coding agent is chosen **per session**. An installed model lists every agent it can be
|
||||||
|
run with (`install.harnesses` of the GET), and the window shows them as chips under "Start a
|
||||||
|
session with": the agent the model was installed with is the default, and the choice is
|
||||||
|
remembered on the device. The launch check asks about the endpoint and the model, never about
|
||||||
|
the agent, so a session with one agent and a session with another run on the same model.
|
||||||
|
|
||||||
|
| Agent | First request (its fixed prompt) | Installed from the window | What was seen, and where |
|
||||||
|
| ---------------- | ------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| OpenCode | about 8,100 tokens, 11 tools (v1.19.5; more with user-level skills) | yes | A tool round trip on a Mac with a real model (the catalogue's tested entry), and on a scripted endpoint for v1.19.5 |
|
||||||
|
| Claude Code | about 36,000 tokens, so the first answer takes minutes | yes | Shipped in 1.50.0; not measured again for this table |
|
||||||
|
| Pi | 2,096 tokens, 4 tools (v1.5.2) | yes | A complete tool round trip against a real model: tool call, tool result, final answer, the file on disk |
|
||||||
|
| DeepSeek Harness | about 7,650 tokens, 25 tools (0.1.1-rc.2); 6,983, 27 tools (0.2.2-rc.1) | **no** | A tool round trip on a scripted endpoint only, on both versions. Against a real model the launch and the model name reached the runtime, and no turn finished |
|
||||||
|
|
||||||
|
How to read the last column: the runs of 2026-11-10 were made on **Linux**, through Codeman's
|
||||||
|
own custom model launch, against Ollama 0.13.0 with a 1.7B model on a CPU ("a real model") or
|
||||||
|
against a scripted endpoint that replays that runtime's stream of a tool call. A scripted run
|
||||||
|
proves the agent's side of the protocol, not what a model does with its prompt. None of them
|
||||||
|
ran on a Mac or on the pinned runtime. Every agent sent the `name:tag` model name unchanged.
|
||||||
|
|
||||||
|
Three things follow from how the agents differ:
|
||||||
|
|
||||||
|
- **Pi is told the model's window.** Without it Pi takes an unknown model for a
|
||||||
|
128,000-token one and never compacts before the real window is full. Codeman now writes the
|
||||||
|
endpoint's context length into the model list it gives Pi (the custom model feature as a
|
||||||
|
whole, not only this window). Two things remain: a custom model session gives Pi a home
|
||||||
|
directory of its own, so Pi fetches its two search tools again at every start (it needs the
|
||||||
|
network for that; `PI_OFFLINE=1` skips it), and Pi asks "Trust project
|
||||||
|
folder?" in every such session when a folder above the working directory holds agent
|
||||||
|
skills, because the answer is stored in the home it does not keep.
|
||||||
|
- **DeepSeek Harness is a launcher.** `dsh` starts nothing by itself: a session needs a
|
||||||
|
terminal profile, which is third-party code with its own consent in the Run menu. Its
|
||||||
|
install command installs the launcher only, so this window never installs it. It is listed
|
||||||
|
as "Needs setup" until the Run menu can start it, and as a normal agent from then on. It is
|
||||||
|
the one agent of the four besides Claude Code that reports the end of a turn. Its custom
|
||||||
|
endpoint recipe itself was verified with a real model on a llama.cpp server
|
||||||
|
([custom-model-endpoints.md](custom-model-endpoints.md)); what is missing is a finished turn
|
||||||
|
on this feature's runtime. On today's version (0.2.2-rc.1 with dsh-TUI 0.19.0) two more
|
||||||
|
things were seen: a setup guide opens in every session until a person has finished it once
|
||||||
|
(a person in a tab presses through it; a script must not, see the codeman skill), and its
|
||||||
|
transcript file was renamed, so Codeman's response viewer and `last-response` are empty for
|
||||||
|
a dsh session on that version until the reader learns the new name.
|
||||||
|
- **OpenCode and DeepSeek Harness send a second, small request** beside the first prompt (a
|
||||||
|
title, a side question). The runtime answers one request at a time, so it queues.
|
||||||
|
|
||||||
|
### Installing one more agent beside an installed model
|
||||||
|
|
||||||
|
An agent that is not on the Mac has "Not installed" on its chip. Selecting it shows the
|
||||||
|
literal install command, and the main button becomes "Install <agent>". That click is the
|
||||||
|
consent; `POST /api/local-model/agent` then runs the same stock command under the same rules
|
||||||
|
as the `agent` step above (catalogue row only, compiled command only, admin only, audited,
|
||||||
|
one installer at a time).
|
||||||
|
|
||||||
|
It is **not a job**. The state stays `installed` whatever the installer does: the model stays
|
||||||
|
loaded, sessions are not refused, and a session with another agent can be started while it
|
||||||
|
runs. A failed install shows the sentence and the installer's last lines under the chip, and
|
||||||
|
leaves everything else as it was. The outcome is kept in memory only, so a restart of the
|
||||||
|
server forgets a failure (the chip then simply says "Not installed" again).
|
||||||
|
|
||||||
### The connection
|
### The connection
|
||||||
|
|
||||||
Two writes outside the local model folder, both undone by Remove:
|
Two writes outside the local model folder, both undone by Remove:
|
||||||
@@ -240,14 +320,17 @@ Switching the setting on also starts that five-minute sweep for every other save
|
|||||||
|
|
||||||
## Starting a session
|
## Starting a session
|
||||||
|
|
||||||
"Start a session" starts the coding agent on the endpoint `local-ollama` through the
|
"Start a session" starts the selected coding agent on the endpoint `local-ollama` through the
|
||||||
existing custom model launch. Before a coding agent is STARTED on that endpoint the server
|
existing custom model launch. Before a coding agent is STARTED on that endpoint the server
|
||||||
checks, in this order:
|
checks, in this order:
|
||||||
|
|
||||||
|
- no job that changes the model in use is running or stopped: until an install or a switch
|
||||||
|
ended, nothing is started ("The model on this Mac is being changed and is not ready");
|
||||||
- the runtime child Codeman started is alive (whatever else answers on the port is not
|
- the runtime child Codeman started is alive (whatever else answers on the port is not
|
||||||
ours);
|
ours);
|
||||||
- the digest the runtime reports for the installed model is the one stored at install time;
|
- the digest the runtime reports for the model in use is the one stored when it was checked;
|
||||||
- the model asked for is the one that was installed and checked.
|
- the model asked for is the model in use. Another model that is on this Mac is not started
|
||||||
|
on the endpoint: switch to it in the window first.
|
||||||
|
|
||||||
That check runs wherever a new agent process comes to be on the endpoint: a session created
|
That check runs wherever a new agent process comes to be on the endpoint: a session created
|
||||||
with the model, a model applied to a running session (which restarts its agent), the attach
|
with the model, a model applied to a running session (which restarts its agent), the attach
|
||||||
@@ -266,29 +349,86 @@ The codeman skill ([`skills/codeman`](../skills/codeman/SKILL.md#claude-in-the-c
|
|||||||
lets an agent in a Codeman session hand work to this model, so Claude in the cloud plans and
|
lets an agent in a Codeman session hand work to this model, so Claude in the cloud plans and
|
||||||
reviews while the model on this Mac does the bulk: summaries, classification, first drafts, and
|
reviews while the model on this Mac does the bulk: summaries, classification, first drafts, and
|
||||||
files that should not leave the machine. `local_model` reads `GET /api/local-model` and says what
|
files that should not leave the machine. `local_model` reads `GET /api/local-model` and says what
|
||||||
is installed or why nothing can run; `spawn_worker <name> local` starts the runtime when it was
|
is installed or why nothing can run, with the coding agents the model can be run with and the
|
||||||
stopped (as Start does), then starts the installed coding agent on `local-ollama` through the same
|
models on the Mac; `spawn_worker <name> local` starts the runtime when it was
|
||||||
|
stopped (as Start does), then starts the default coding agent (`install.cli`), or with
|
||||||
|
`local:<agent>` (`local:pi`, `local:deepseek`) another one that is ready, on `local-ollama` through the same
|
||||||
quick-start launch and the same checks as above; `sendmark` gives the worker a task and reads its
|
quick-start launch and the same checks as above; `sendmark` gives the worker a task and reads its
|
||||||
answer back from a file it writes in its own folder. The skill never installs a model and never
|
answer back from a file it writes in its own folder. The skill never installs a model or a coding agent and never
|
||||||
answers a confirmation, the runtime still answers one request at a time, and while a worker it
|
answers a confirmation, the runtime still answers one request at a time, and while a worker it
|
||||||
opened is live, Stop and Remove are refused until it is closed.
|
opened is live, Stop and Remove are refused until it is closed.
|
||||||
|
|
||||||
|
## The models on this Mac
|
||||||
|
|
||||||
|
The model store can hold several models. **One of them is the model in use**: the one the
|
||||||
|
endpoint record `local-ollama` lists, the runtime is started for and a session can be
|
||||||
|
started on. The runtime holds one model in memory at a time (`OLLAMA_MAX_LOADED_MODELS=1`),
|
||||||
|
so this is what the Mac can really do, and it keeps every rule above: one record, one
|
||||||
|
model, one stored digest per model.
|
||||||
|
|
||||||
|
The installed screen lists what is on this Mac, the model in use first:
|
||||||
|
|
||||||
|
| A row says | It means |
|
||||||
|
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| In use | The model sessions start on |
|
||||||
|
| Ready | Codeman downloaded and checked it. **Use** switches to it |
|
||||||
|
| Not checked | The runtime lists it and Codeman never checked it: a model an earlier Remove kept, or one another program pulled through the runtime's port. It can only be deleted |
|
||||||
|
| Changed | It was checked, and the runtime now reports another digest under its name. It is never started; delete it and install it again |
|
||||||
|
| Missing | It was checked, and the runtime no longer lists it. **Delete** drops the entry |
|
||||||
|
|
||||||
|
Each row shows the context the model was checked with and its size on disk, as the runtime
|
||||||
|
reports it. Under the list is what all downloads take together: the size of the files in
|
||||||
|
`local-model/models/blobs`, so layers two models share are counted once and an interrupted
|
||||||
|
download is included. The window asks the runtime for the list at most every five seconds
|
||||||
|
and measures the store at most every thirty, never on every poll.
|
||||||
|
|
||||||
|
- **Add a model** opens the chooser. A model that is not on this Mac is installed like the
|
||||||
|
first one, with its sizes confirmed; when the job ends it is the model in use, and the
|
||||||
|
earlier model stays in the list. A row that is on this Mac already says so and is switched
|
||||||
|
to, without a download and without a confirm screen.
|
||||||
|
- **Use** switches to a checked model. It is a job of two steps, `verify` and `connect`:
|
||||||
|
the runtime is restarted with the context the model was checked with, the model is loaded
|
||||||
|
and checked again (the tiny chat, the GPU, the tool probe), its digest must still be the
|
||||||
|
stored one (else the switch fails with `model-changed` before the model is loaded), and
|
||||||
|
the endpoint record is rewritten. The default coding agent becomes the one the model was
|
||||||
|
checked with when it is still installed, else the one of the installation, else any other
|
||||||
|
agent that is on the Mac and can run the model.
|
||||||
|
- **Delete** removes one model through the runtime's own delete, after one question in the
|
||||||
|
row. The request carries the model's name, never a path, and the server deletes only a
|
||||||
|
name of its own listing. The model in use cannot be deleted: switch first, or Remove. It
|
||||||
|
needs the running runtime, so a stopped model shows no Delete until Start.
|
||||||
|
|
||||||
|
Adding a model and switching are refused while a session uses the model ("Sessions are
|
||||||
|
still using this model"), like Stop: their coding agents were started with the other model's
|
||||||
|
name, and the runtime would swap models on every request.
|
||||||
|
|
||||||
|
While a job that started from an installed model runs, or after it stopped (paused or
|
||||||
|
failed), no session is started: the checks above want a finished installation, and the
|
||||||
|
refusal says so (`job-unfinished`). The window
|
||||||
|
then lists the checked models with **Use**, which is the way back to the earlier one. Going
|
||||||
|
back deletes what the stopped job left of a model that never passed its check, the way a new
|
||||||
|
install does; a checked model is never deleted that way.
|
||||||
|
|
||||||
## Stop, Start, Remove
|
## Stop, Start, Remove
|
||||||
|
|
||||||
- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes
|
- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes
|
||||||
that.
|
that, and so does a switch to another model or the install of one: the job starts the
|
||||||
|
runtime, and the model it ends on is in use.
|
||||||
- **Remove** stops the runtime, removes the endpoint record, switches the setting off again
|
- **Remove** stops the runtime, removes the endpoint record, switches the setting off again
|
||||||
only when this feature switched it on and no other endpoint is saved, and deletes the
|
only when this feature switched it on and no other endpoint is saved, and deletes the
|
||||||
runtime and its private home. The downloaded model is deleted only when asked. The coding
|
runtime and its private home. The downloaded models (all of them) are deleted only when
|
||||||
agent stays. While a live session uses the model, Remove answers with the session names
|
asked. The coding agent stays. While a live session uses the model, Remove answers with
|
||||||
instead.
|
the session names instead.
|
||||||
|
- **Models a Remove kept** are not lost space: with nothing installed the window says how
|
||||||
|
much they still take and offers "Delete them", and after a new install they show in the
|
||||||
|
list as "Not checked", each with its own Delete.
|
||||||
|
|
||||||
## Where everything is
|
## Where everything is
|
||||||
|
|
||||||
All under the data directory (`~/.codeman`, or the instance's own):
|
All under the data directory (`~/.codeman`, or the instance's own):
|
||||||
|
|
||||||
```
|
```
|
||||||
local-model.json the state file (mode 0600)
|
local-model.json the state file (mode 0600), with the list of checked models
|
||||||
local-model/
|
local-model/
|
||||||
runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads
|
runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads
|
||||||
runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB)
|
runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB)
|
||||||
@@ -313,9 +453,12 @@ in App Settings.
|
|||||||
| ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- |
|
| ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- |
|
||||||
| `idle` | stays idle | refused | install |
|
| `idle` | stays idle | refused | install |
|
||||||
| `installing` | becomes `paused` at the same step | becomes `paused` | cancel |
|
| `installing` | becomes `paused` at the same step | becomes `paused` | cancel |
|
||||||
| `paused` | stays paused | refused | resume, install, remove |
|
| `paused` | stays paused | refused | resume, install, use, delete a model, remove |
|
||||||
| `failed` | stays failed, same error | refused | resume, install, remove |
|
| `failed` | stays failed, same error | refused | resume, install, use, delete a model, remove |
|
||||||
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove |
|
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, install (add a model), use, delete a model, remove |
|
||||||
|
|
||||||
|
`use` and "delete a model" are listed only while a row of the list offers them, and in
|
||||||
|
`idle` "remove" is offered only while the model store still holds something.
|
||||||
|
|
||||||
One job at a time. A job a server restart interrupted comes back paused and never continues
|
One job at a time. A job a server restart interrupted comes back paused and never continues
|
||||||
without a click. Cancel interrupts the step in flight (the download, the pull, the request,
|
without a click. Cancel interrupts the step in flight (the download, the pull, the request,
|
||||||
@@ -351,12 +494,25 @@ or `python3`.
|
|||||||
a session uses the model: close those sessions first.
|
a session uses the model: close those sessions first.
|
||||||
- **The runtime does not enforce the context length.** A prompt longer than the context was
|
- **The runtime does not enforce the context length.** A prompt longer than the context was
|
||||||
accepted in testing. Do not rely on the runtime to stop one.
|
accepted in testing. Do not rely on the runtime to stop one.
|
||||||
- **Most memory figures are calculated, not measured.** One model was run on one Mac; the
|
- **Most memory figures are calculated, not measured.** Two models were measured on one Mac;
|
||||||
others are computed from their architecture, and the runtime settings they assume (an 8-bit
|
the others are computed from their architecture, and the runtime settings they assume (an 8-bit
|
||||||
K/V cache, flash attention, a 1 GiB prompt cache) were read in its source, not yet observed
|
K/V cache, flash attention, a 1 GiB prompt cache) were read in its source, not yet observed
|
||||||
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
|
on a Mac. "Recommended" appears only for the pair we ran, on the Mac we ran it on.
|
||||||
|
- **A tool call that quotes a tool call can be cut off.** The pinned runtime reads the tool
|
||||||
|
calls of every Qwen-family model in the catalogue (the Ornith, MiMo and Qwen rows, the pair
|
||||||
|
we ran included) with one parser, and that parser ends a call at the first closing tag it
|
||||||
|
sees. An argument that itself contains a whole tool call, such as a prompt handed to a
|
||||||
|
sub-agent that shows one, arrives cut off and the rest as text (runtime issue 16992, filed
|
||||||
|
on `ornith:35b`; the fix, runtime pull request 17021, is open). The check after a download
|
||||||
|
asks for one plain tool call and does not see this.
|
||||||
- **The model stays in memory for ten minutes** after the last request, then the runtime
|
- **The model stays in memory for ten minutes** after the last request, then the runtime
|
||||||
unloads it.
|
unloads it.
|
||||||
|
- **One model runs at a time.** Several can be on disk; the runtime holds one, and a switch
|
||||||
|
restarts it and loads the other model (seconds to a minute). Close the sessions on the
|
||||||
|
model first: the window refuses the switch while one is open.
|
||||||
|
- **The disk figures are the runtime's.** A row's size is what the runtime reports for the
|
||||||
|
model, and the total is the size of its blob files. Neither was compared with Finder on a
|
||||||
|
Mac yet.
|
||||||
|
|
||||||
## Keeping the catalogue current
|
## Keeping the catalogue current
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
|
|||||||
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
|
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **Implemented (1.42.0):** `PUT /api/settings`, `PUT /api/execution/model-config`, `PUT /api/config` and `PUT /api/cases/order` answer 403 `FORBIDDEN` to a non-admin before parsing anything; quick-start and Ralph loop start skip a non-admin's `lastUsedCase`; the browser skips those writes for a non-admin (`_settingsReadOnly()`, settings-ui.js) and keeps their own run mode and respawn presets on the device; a non-admin's `GET /api/settings` omits `voiceSettings.apiKey`, `caseOrder`, `lastUsedCase`, `lastPickedCase` and `caseSettings` (`settingsForNonAdmin`). Per-user server settings: out of scope v1|
|
||||||
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only. **Implemented (1.42.0):** `POST /api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate` and `/api/cleanup-state` answer 403 `FORBIDDEN` to a non-admin; a non-admin's `POST /api/auth/revoke` revokes only their own login sessions and `GET /api/tunnel/info` lists only those |
|
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only. **Implemented (1.42.0):** `POST /api/system/update`, `/api/system/span-displays`, `/api/tunnel/qr/regenerate` and `/api/cleanup-state` answer 403 `FORBIDDEN` to a non-admin; a non-admin's `POST /api/auth/revoke` revokes only their own login sessions and `GET /api/tunnel/info` lists only those |
|
||||||
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
|
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -55,6 +55,19 @@ documented default `tools.approvalMode` is `yolo`, so an omp pane auto-approves
|
|||||||
with no flag from Codeman — the CLI's own config, not Codeman, is what would need to
|
with no flag from Codeman — the CLI's own config, not Codeman, is what would need to
|
||||||
change that.
|
change that.
|
||||||
|
|
||||||
|
**Approval alerts.** When a session is created (`POST /api/sessions` or
|
||||||
|
`POST /api/quick-start`, local workspaces only, and only while `workspaceHooksEnabled`
|
||||||
|
is on), Codeman writes a hook module, `.claude/hooks/pre/codeman-approval.ts`, into the
|
||||||
|
workspace; omp loads project hooks from that folder. It forwards omp's
|
||||||
|
`tool_approval_requested` / `tool_approval_resolved` events to `/api/hook-event`, so a
|
||||||
|
pending approval raises a tab alert, a notification and an Approvals Inbox item. The
|
||||||
|
item is **alert-only**: answer the dialog in the omp pane (omp's composer keeps taking
|
||||||
|
typing while the prompt is docked, so Codeman never types an answer for you). The
|
||||||
|
registry declares this as `capabilities.approvalHookFile`, separate from `hooks`, which
|
||||||
|
stays `'none'`: the `blocked` wait signal works for omp, `stop` is still a 400. ⚠️ With
|
||||||
|
omp's default `tools.approvalMode: yolo` omp never asks, so the module only does
|
||||||
|
anything once that setting is changed.
|
||||||
|
|
||||||
Env overrides: the `OMP_*` prefix is allowlisted, and per omp's own
|
Env overrides: the `OMP_*` prefix is allowlisted, and per omp's own
|
||||||
`docs/environment-variables.md` it is not the narrow surface it looks like. omp reads
|
`docs/environment-variables.md` it is not the narrow surface it looks like. omp reads
|
||||||
roughly 40 provider keys from the environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
|
roughly 40 provider keys from the environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
|
||||||
@@ -163,13 +176,13 @@ remote user's home, so resolving locally would pin a stranger's id. See
|
|||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
- **No idle/completion hook.** Idle detection reads the screen instead: the
|
- **No idle/completion hook.** The approval hook module (see Approval alerts
|
||||||
|
above) reports approvals only, so idle detection reads the screen instead: the
|
||||||
registry entry's `workDetect` names omp's `╰─` input row as the glyph that arms
|
registry entry's `workDetect` names omp's `╰─` input row as the glyph that arms
|
||||||
the idle check, and the status bar's spinner plus elapsed time (` ⠼ 14s > ⬢ …`)
|
the idle check, and the status bar's spinner plus elapsed time (` ⠼ 14s > ⬢ …`)
|
||||||
or the `⎋ Working…` row as the working line, measured on omp 18.8.6 and 18.0.11.
|
or the `⎋ Working…` row as the working line, measured on omp 18.8.6 and 18.0.11.
|
||||||
Without it an omp session that had started a turn never left `busy`. If omp ever
|
Without it an omp session that had started a turn never left `busy`. Reporting
|
||||||
ships a hooks system, a Codeman hook POSTing to `/api/hook-event` would still be
|
end-of-turn from the same module would be the follow-up that earns omp `stop`.
|
||||||
the highest-value follow-up.
|
|
||||||
- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session`
|
- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session`
|
||||||
before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct
|
before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct
|
||||||
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
|
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
|
||||||
|
|||||||
@@ -363,5 +363,5 @@ The Orchestrator can optionally USE the Ralph Loop internally for phase executio
|
|||||||
| `src/types/orchestrator.ts` | Type definitions |
|
| `src/types/orchestrator.ts` | Type definitions |
|
||||||
| `src/prompts/orchestrator.ts` | Prompt templates |
|
| `src/prompts/orchestrator.ts` | Prompt templates |
|
||||||
| `src/web/routes/orchestrator-routes.ts` | API endpoints |
|
| `src/web/routes/orchestrator-routes.ts` | API endpoints |
|
||||||
| `src/web/public/orchestrator-ui.js` | Frontend panel |
|
| `src/web/public/orchestrator-panel.js` | Frontend panel |
|
||||||
| `src/web/ports/orchestrator-port.ts` | Port interface |
|
| `src/web/ports/orchestrator-port.ts` | Port interface |
|
||||||
@@ -837,8 +837,6 @@ POST /api/sessions/:id/auto-clear
|
|||||||
- `src/ralph-tracker.ts` - Core detection engine
|
- `src/ralph-tracker.ts` - Core detection engine
|
||||||
- `src/ralph-loop.ts` - Task orchestration
|
- `src/ralph-loop.ts` - Task orchestration
|
||||||
- `src/respawn-controller.ts` - Session cycling
|
- `src/respawn-controller.ts` - Session cycling
|
||||||
- `src/spawn-orchestrator.ts` - Autonomous agent lifecycle (uses RalphTracker for completion)
|
|
||||||
- `src/spawn-detector.ts` - Detects `<spawn1337>` tags in terminal output
|
|
||||||
- `src/types.ts` - Type definitions
|
- `src/types.ts` - Type definitions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
|
|||||||
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
|
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
|
||||||
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
|
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
|
||||||
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
|
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
|
||||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok' \| 'deepseek' \| 'omp'>` — the modes that can run remotely. |
|
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok' \| 'deepseek' \| 'omp' \| 'jcode'>` — the modes that can run remotely. |
|
||||||
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
|
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
|
||||||
|
|
||||||
Persistence is two flat JSON arrays in the instance data dir:
|
Persistence is two flat JSON arrays in the instance data dir:
|
||||||
|
|||||||
@@ -79,5 +79,5 @@ Uses temp file for prompt to avoid E2BIG errors with large terminal buffers.
|
|||||||
|
|
||||||
- `test/respawn-scenarios.md` - Comprehensive test scenarios for edge cases
|
- `test/respawn-scenarios.md` - Comprehensive test scenarios for edge cases
|
||||||
- `test/respawn-test-plan.md` - Test environment architecture and strategies
|
- `test/respawn-test-plan.md` - Test environment architecture and strategies
|
||||||
- `test/respawn-test-utils.ts` - Mock utilities (MockSession, MockAiIdleChecker, MockAiPlanChecker)
|
- `test/mocks/mock-session.ts` - `MockSession`, the session double the respawn tests drive
|
||||||
- `test/respawn-analysis.md` - Code coverage analysis and identified issues
|
- `test/respawn-analysis.md` - Code coverage analysis and identified issues
|
||||||
@@ -88,14 +88,18 @@ decision.
|
|||||||
|
|
||||||
### Experimental local model routes
|
### Experimental local model routes
|
||||||
|
|
||||||
`GET /api/v1/local-model`, `POST /api/v1/local-model/install`, `/cancel`, `/resume`
|
`GET /api/v1/local-model`, `POST /api/v1/local-model/install`, `/use`, `/cancel`,
|
||||||
and `/runtime`, and `DELETE /api/v1/local-model` (and their `/api` aliases) are
|
`/resume`, `/runtime` and `/agent`, `DELETE /api/v1/local-model` and
|
||||||
|
`DELETE /api/v1/local-model/models` (and their `/api` aliases) are
|
||||||
labeled experimental, so item 3 above applies to them: their paths, their payloads
|
labeled experimental, so item 3 above applies to them: their paths, their payloads
|
||||||
(the hardware facts, the verdicts and reasons, the candidate fields, the `install`
|
(the hardware facts, the verdicts and reasons, the candidate fields, the `install`
|
||||||
object with its states, steps, error codes and actions, the 409 conflict shape and
|
object with its states, steps, error codes and actions, its `library`, `store` and
|
||||||
|
`harnesses` lists, the 409 conflict shape and
|
||||||
every English sentence) and their behavior may change or be removed in a MINOR or
|
every English sentence) and their behavior may change or be removed in a MINOR or
|
||||||
PATCH release. They look at the Mac the server runs on, say which local model fits,
|
PATCH release. They look at the Mac the server runs on, say which local model fits,
|
||||||
and install, start, stop and remove one ([local-models.md](local-models.md)).
|
install, start, stop and remove one, keep a library of installed models to switch
|
||||||
|
between or delete, and install one more coding agent beside an installed model
|
||||||
|
([local-models.md](local-models.md)).
|
||||||
|
|
||||||
They are not behind a setting. Reading changes nothing; an install starts only on an
|
They are not behind a setting. Reading changes nothing; an install starts only on an
|
||||||
explicit request that carries the size the person confirmed. All of them are admin
|
explicit request that carries the size the person confirmed. All of them are admin
|
||||||
|
|||||||
+41
-10
@@ -1,9 +1,9 @@
|
|||||||
# Agent CLIs
|
# Agent CLIs
|
||||||
|
|
||||||
Codeman drives eleven run modes: ten agent CLIs plus a plain shell. This page covers picking
|
Codeman drives twelve run modes: eleven agent CLIs plus a plain shell. This page covers picking
|
||||||
one, setting it up, and the differences that actually change how you work.
|
one, setting it up, and the differences that actually change how you work.
|
||||||
|
|
||||||
## The eleven modes
|
## The twelve modes
|
||||||
|
|
||||||
| Mode | CLI | Get it |
|
| Mode | CLI | Get it |
|
||||||
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
|
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
|
||||||
@@ -17,6 +17,7 @@ one, setting it up, and the differences that actually change how you work.
|
|||||||
| **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
|
| **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
|
||||||
| **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) |
|
| **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) |
|
||||||
| **GitHub Copilot** | `copilot` | [github.com/github/copilot-cli](https://github.com/github/copilot-cli) |
|
| **GitHub Copilot** | `copilot` | [github.com/github/copilot-cli](https://github.com/github/copilot-cli) |
|
||||||
|
| **jcode** | `jcode` | [jcode.sh](https://jcode.sh) |
|
||||||
| **Terminal / Shell** | your `$SHELL` | Already installed. |
|
| **Terminal / Shell** | your `$SHELL` | Already installed. |
|
||||||
|
|
||||||
Any combination works, including all of them. The run mode is chosen per session from the
|
Any combination works, including all of them. The run mode is chosen per session from the
|
||||||
@@ -51,10 +52,11 @@ If a CLI is installed but a Run button for it never appears:
|
|||||||
precisely to avoid this; a hand-written plist or unit will not.
|
precisely to avoid this; a hand-written plist or unit will not.
|
||||||
3. Restart the server after installing a new CLI.
|
3. Restart the server after installing a new CLI.
|
||||||
|
|
||||||
`pi`, `grok`, `omp`, `copilot` and `dsh` are additionally identity-probed rather than trusted by name:
|
`pi`, `grok`, `omp`, `copilot`, `dsh` and `jcode` are additionally identity-probed rather than trusted by name:
|
||||||
`pi` and `omp` are generic enough that something else on your PATH may answer to them,
|
`pi` and `omp` are generic enough that something else on your PATH may answer to them,
|
||||||
`grok` has npm squatters, and Debian ships an unrelated `dsh` (dancer's shell). Each has a
|
`grok` has npm squatters, an unrelated npm package is also called `jcode`, and Debian ships an
|
||||||
status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`, `/api/copilot/status`) that reports
|
unrelated `dsh` (dancer's shell). Each has a status endpoint (`/api/grok/status`,
|
||||||
|
`/api/deepseek/status`, `/api/omp/status`, `/api/copilot/status`, `/api/jcode/status`) that reports
|
||||||
the path and version that actually resolved, so a misresolution is visible rather than
|
the path and version that actually resolved, so a misresolution is visible rather than
|
||||||
presenting as "the mode just does not work".
|
presenting as "the mode just does not work".
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ output. The other CLIs expose no equivalent.
|
|||||||
| Respawn cycling and unattended runs | Yes | Yes |
|
| Respawn cycling and unattended runs | Yes | Yes |
|
||||||
| Cron jobs | Yes | Yes |
|
| Cron jobs | Yes | Yes |
|
||||||
| Docker cases, remote SSH cases | Yes | Yes |
|
| Docker cases, remote SSH cases | Yes | Yes |
|
||||||
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini and Antigravity: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable |
|
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini, Antigravity and jcode: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable |
|
||||||
| Auto-resume when a usage limit resets | Yes | No |
|
| Auto-resume when a usage limit resets | Yes | No |
|
||||||
| Plan usage chip | Yes | Codex (limits) and Copilot (monthly premium requests); the others no |
|
| Plan usage chip | Yes | Codex (limits) and Copilot (monthly premium requests); the others no |
|
||||||
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
|
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
|
||||||
@@ -108,9 +110,10 @@ The defaults you will care about, all under **App Settings**:
|
|||||||
**Separate Claude accounts per session.** Set `CLAUDE_CONFIG_DIR` in a session's environment
|
**Separate Claude accounts per session.** Set `CLAUDE_CONFIG_DIR` in a session's environment
|
||||||
overrides to point it at a different Claude config directory, which is how you run one
|
overrides to point it at a different Claude config directory, which is how you run one
|
||||||
session on a client's subscription and another on your own. One caveat: a relocated config
|
session on a client's subscription and another on your own. One caveat: a relocated config
|
||||||
directory writes transcripts outside `~/.claude/projects`, which blinds the response viewer,
|
directory writes transcripts outside `~/.claude/projects`, which blinds subagent windows,
|
||||||
subagent windows, ultracode panel, and Read My Mind for that session. Symlink `projects`
|
the ultracode panel and Read My Mind for that session (the response viewer and the Edit
|
||||||
back into the shared tree to keep them working:
|
history panel read the relocated directory). Symlink `projects` back into the shared tree
|
||||||
|
to keep them working:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ln -s ~/.claude/projects <configDir>/projects
|
ln -s ~/.claude/projects <configDir>/projects
|
||||||
@@ -352,6 +355,33 @@ Ralph tracker, as for the other non-Claude CLIs.
|
|||||||
|
|
||||||
Guide: [`docs/copilot-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/copilot-integration.md).
|
Guide: [`docs/copilot-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/copilot-integration.md).
|
||||||
|
|
||||||
|
### jcode
|
||||||
|
|
||||||
|
A coding agent written in Rust, installed with `curl -fsSL https://jcode.sh/install | bash`
|
||||||
|
into `~/.local/bin`. jcode keeps its own logins and provider choice (`jcode login`, `/login`,
|
||||||
|
`~/.jcode/config.toml`); there is no Codeman-side login, key field, or bypass switch, and Run
|
||||||
|
sends no jcode config at all, because jcode never asks before running a tool. A session can
|
||||||
|
be started on a model (`--model`) or resume a jcode session by its id or short name
|
||||||
|
(`--resume`).
|
||||||
|
|
||||||
|
Four things worth knowing:
|
||||||
|
|
||||||
|
- **Run `jcode` once in a terminal first.** A fresh install opens an onboarding wizard
|
||||||
|
instead of the prompt for its first few launches, and a prompt typed into that wizard is
|
||||||
|
lost. Finish or skip it once (Esc), and Codeman sessions open straight at the prompt.
|
||||||
|
- **All jcode sessions share one background daemon**, which jcode starts the first time and
|
||||||
|
keeps running after the tab that started it is closed. jcode's tools run inside that
|
||||||
|
daemon, so they see the daemon's environment, not the tab's. For that reason Codeman does
|
||||||
|
not inject its agent skill into jcode, and accepts no per-session `JCODE_*` environment
|
||||||
|
variables: set those in `~/.jcode/config.toml` or in the server's own environment.
|
||||||
|
- **Read a jcode worker from its terminal.** Codeman cannot read jcode's answers back yet,
|
||||||
|
so the Response Viewer and `last-response` stay empty for it; `codeman agent read <id>
|
||||||
|
--tail 3000` (or the terminal tail) shows what it said.
|
||||||
|
- **Idle detection is the precise kind**: Codeman reads jcode's spinner row above the
|
||||||
|
prompt, and the wheel or a swipe pages jcode's own transcript.
|
||||||
|
|
||||||
|
Guide: [`docs/jcode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/jcode-integration.md).
|
||||||
|
|
||||||
### Terminal / Shell
|
### Terminal / Shell
|
||||||
|
|
||||||
A plain shell in a tmux session. No agent, no hooks, no idle detection.
|
A plain shell in a tmux session. No agent, no hooks, no idle detection.
|
||||||
@@ -379,6 +409,7 @@ respawns. Which variables are accepted depends on the mode:
|
|||||||
| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
|
| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
|
||||||
| OMP | `OMP_*` |
|
| OMP | `OMP_*` |
|
||||||
| Copilot | `COPILOT_*` (a token goes in `COPILOT_GITHUB_TOKEN`; `GH_TOKEN` and `GITHUB_TOKEN` are not accepted as per-session overrides) |
|
| Copilot | `COPILOT_*` (a token goes in `COPILOT_GITHUB_TOKEN`; `GH_TOKEN` and `GITHUB_TOKEN` are not accepted as per-session overrides) |
|
||||||
|
| jcode | none (its tools run in a shared daemon, see [jcode](#jcode)) |
|
||||||
|
|
||||||
Anything outside the allowlist is rejected at the schema. This is intentional: the allowlist
|
Anything outside the allowlist is rejected at the schema. This is intentional: the allowlist
|
||||||
is one global list, so widening it for one CLI widens it for all of them. In multi-user mode
|
is one global list, so widening it for one CLI widens it for all of them. In multi-user mode
|
||||||
@@ -394,7 +425,7 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working.
|
|||||||
|
|
||||||
- **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit
|
- **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit
|
||||||
auto-resume, the Approvals Inbox, and subagent visualization all assume it.
|
auto-resume, the Approvals Inbox, and subagent visualization all assume it.
|
||||||
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP, GitHub Copilot** when you prefer that agent or that
|
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP, GitHub Copilot, jcode** when you prefer that agent or that
|
||||||
model. You get the session layer, respawn, cron, Docker, and remote SSH; you do not get the
|
model. You get the session layer, respawn, cron, Docker, and remote SSH; you do not get the
|
||||||
hook-driven features (Codex dialogs still raise the red alert and an alert-only inbox card,
|
hook-driven features (Codex dialogs still raise the red alert and an alert-only inbox card,
|
||||||
see [Codex](#codex) above).
|
see [Codex](#codex) above).
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ from your side. See [Agent CLIs](Agent-CLIs) for the run modes.
|
|||||||
Claude Code lead can hand work to it: Claude in the cloud plans and reviews, the local model
|
Claude Code lead can hand work to it: Claude in the cloud plans and reviews, the local model
|
||||||
takes summaries, first drafts and files that should stay on the machine. It answers one
|
takes summaries, first drafts and files that should stay on the machine. It answers one
|
||||||
request at a time and is slower than a cloud model, and the skill never installs one for you.
|
request at a time and is slower than a cloud model, and the skill never installs one for you.
|
||||||
|
You can name the coding agent the worker runs with ("a local worker with Pi"), as long as
|
||||||
|
that agent is installed; the walkthrough is in [Local Models](Local-Models#cloud-and-local-together).
|
||||||
|
|
||||||
With Claude Code workers, where the installed Claude Code supports it, the lead can also
|
With Claude Code workers, where the installed Claude Code supports it, the lead can also
|
||||||
message a worker directly instead of typing into its terminal.
|
message a worker directly instead of typing into its terminal.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ cron jobs.
|
|||||||
A field a file rewrites on its own is not a change, and the comparison looks through it (the
|
A field a file rewrites on its own is not a change, and the comparison looks through it (the
|
||||||
snapshot still stores the file exactly as written, and a restore puts those fields back as they
|
snapshot still stores the file exactly as written, and a restore puts those fields back as they
|
||||||
were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five
|
were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five
|
||||||
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `intents.json`
|
minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase` and `lastPickedCase`, `intents.json`
|
||||||
`recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json`
|
`recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json`
|
||||||
`lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json`
|
`lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json`
|
||||||
`lastOpenedAt` (opening a web tab). Without that, every
|
`lastOpenedAt` (opening a web tab). Without that, every
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ Conventions for wiki pages:
|
|||||||
- Images are referenced from the main repository over raw URLs rather than being copied into
|
- Images are referenced from the main repository over raw URLs rather than being copied into
|
||||||
the wiki.
|
the wiki.
|
||||||
- Say what the default is, especially when it is off. Most of Codeman is opt-in.
|
- Say what the default is, especially when it is off. Most of Codeman is opt-in.
|
||||||
- Label Claude-only behaviour every time it appears. Ten of the eleven run modes are not
|
- Label Claude-only behaviour every time it appears. Eleven of the twelve run modes are not
|
||||||
Claude.
|
Claude.
|
||||||
|
|
||||||
## Conduct
|
## Conduct
|
||||||
|
|||||||
+16
-16
@@ -92,10 +92,10 @@ A session carries state the case does not:
|
|||||||
## Run mode
|
## Run mode
|
||||||
|
|
||||||
The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`,
|
The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`,
|
||||||
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, or `shell`. It is chosen at start and does not change afterwards; to
|
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, `jcode`, or `shell`. It is chosen at start and does not change afterwards; to
|
||||||
switch, start another session.
|
switch, start another session.
|
||||||
|
|
||||||
Claude is the reference mode. Nine of the ten are not Claude, and a number of Codeman
|
Claude is the reference mode. Eleven of the twelve are not Claude, and a number of Codeman
|
||||||
features are Claude-only for structural reasons rather than missing effort: they depend on
|
features are Claude-only for structural reasons rather than missing effort: they depend on
|
||||||
Claude Code's hook system or on parsing its terminal output. Every such feature is labelled
|
Claude Code's hook system or on parsing its terminal output. Every such feature is labelled
|
||||||
Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one place.
|
Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one place.
|
||||||
@@ -110,8 +110,8 @@ Where a case runs is **separate from** which CLI it runs. There are three locati
|
|||||||
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
|
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
|
||||||
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
|
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
|
||||||
|
|
||||||
This matters because it is a common source of confusion: Docker is **not** an eleventh run
|
This matters because it is a common source of confusion: Docker is **not** a thirteenth run
|
||||||
mode. All eleven run modes work in all three locations. A case is docker-backed or
|
mode. All twelve run modes work in all three locations. A case is docker-backed or
|
||||||
ssh-backed; a session is claude or codex or shell.
|
ssh-backed; a session is claude or codex or shell.
|
||||||
|
|
||||||
**Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a
|
**Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a
|
||||||
@@ -213,21 +213,21 @@ See [Hooks And Integrations](Hooks-And-Integrations).
|
|||||||
|
|
||||||
## Vocabulary
|
## Vocabulary
|
||||||
|
|
||||||
| Term | Means |
|
| Term | Means |
|
||||||
| ---------------- | ------------------------------------------------------------------------------------------------- |
|
| ---------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
| **Case** | Named working directory. |
|
| **Case** | Named working directory. |
|
||||||
| **Session** | One CLI in one tmux session. |
|
| **Session** | One CLI in one tmux session. |
|
||||||
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. |
|
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, jcode, shell. |
|
||||||
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
|
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
|
||||||
| **Ralph loop** | An autonomous single-session task loop. |
|
| **Ralph loop** | An autonomous single-session task loop. |
|
||||||
| **Orchestrator** | A phased plan driven across multiple agents. |
|
| **Orchestrator** | A phased plan driven across multiple agents. |
|
||||||
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
|
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
|
||||||
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
|
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
|
||||||
| **Instance** | One Codeman server with its own data directory and tmux socket. |
|
| **Instance** | One Codeman server with its own data directory and tmux socket. |
|
||||||
|
|
||||||
## Read next
|
## Read next
|
||||||
|
|
||||||
- [The Dashboard](The-Dashboard) - what the UI is showing you.
|
- [The Dashboard](The-Dashboard) - what the UI is showing you.
|
||||||
- [Agent CLIs](Agent-CLIs) - the eleven run modes in detail.
|
- [Agent CLIs](Agent-CLIs) - the twelve run modes in detail.
|
||||||
- [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits.
|
- [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits.
|
||||||
- [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors.
|
- [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors.
|
||||||
@@ -207,7 +207,9 @@ missing entry is the more current answer.
|
|||||||
## A model that runs on this Mac
|
## A model that runs on this Mac
|
||||||
|
|
||||||
If the Codeman server runs on a Mac with Apple silicon, you do not need a server of your
|
If the Codeman server runs on a Mac with Apple silicon, you do not need a server of your
|
||||||
own: Codeman can set one model up for you on that Mac. This is experimental.
|
own: Codeman can set one model up for you on that Mac. This is experimental. The walkthrough,
|
||||||
|
with the coding agents and the cloud plus local setup, is on its own page:
|
||||||
|
[Local Models](Local-Models).
|
||||||
|
|
||||||
1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model
|
1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model
|
||||||
on this Mac).
|
on this Mac).
|
||||||
@@ -234,6 +236,15 @@ Good to know:
|
|||||||
but it is changed and removed in the local model window, not here.
|
but it is changed and removed in the local model window, not here.
|
||||||
- Codeman starts the runtime with the server and stops it with it. **Stop** keeps it from
|
- Codeman starts the runtime with the server and stops it with it. **Stop** keeps it from
|
||||||
starting next time.
|
starting next time.
|
||||||
|
- More than one model can be on the Mac, and one of them runs at a time. Once a model is
|
||||||
|
installed the window lists the models with what each takes on disk: **Add a model**
|
||||||
|
downloads another one, **Use** switches to a model that is already there (nothing is
|
||||||
|
downloaded; close the sessions on the model first), and **Delete** removes one, after one
|
||||||
|
question. The model in use cannot be deleted: switch first, or use **Remove**.
|
||||||
|
- The coding agent is chosen per session. The window lists every agent the model can be run
|
||||||
|
with (OpenCode, Claude Code, Pi, DeepSeek Harness) under **Start a session with**, and
|
||||||
|
installs one that is missing after showing you the command. DeepSeek Harness is set up
|
||||||
|
from the Run menu first.
|
||||||
- **Remove** deletes the runtime and the endpoint, and the model too if you tick that. The
|
- **Remove** deletes the runtime and the endpoint, and the model too if you tick that. The
|
||||||
coding agent stays installed.
|
coding agent stays installed.
|
||||||
- If the model on disk is no longer the one that was installed, Codeman refuses to start a
|
- If the model on disk is no longer the one that was installed, Codeman refuses to start a
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Run a case inside its own container instead of directly on your host: for isolat
|
|||||||
reproducible toolchain, and for the ability to pick the whole environment up and move it to
|
reproducible toolchain, and for the ability to pick the whole environment up and move it to
|
||||||
another machine.
|
another machine.
|
||||||
|
|
||||||
A docker case is a **location overlay**, not a run mode. All eleven run modes work inside a
|
A docker case is a **location overlay**, not a run mode. All twelve run modes work inside a
|
||||||
container. See [Core Concepts](Core-Concepts).
|
container. See [Core Concepts](Core-Concepts).
|
||||||
|
|
||||||
## One-time setup: the base image
|
## One-time setup: the base image
|
||||||
@@ -26,7 +26,7 @@ A zero exit code proves the layers ran, not that the toolchain works. Verify:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm codeman/agent:base bash -lc \
|
docker run --rm codeman/agent:base bash -lc \
|
||||||
'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
|
'for c in claude codex gemini opencode agy pi grok dsh omp jcode; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
|
||||||
```
|
```
|
||||||
|
|
||||||
The image is secret-free. Credentials are delivered at runtime, never baked in, so exports
|
The image is secret-free. Credentials are delivered at runtime, never baked in, so exports
|
||||||
@@ -120,7 +120,9 @@ as whole directories, because those directories also hold sessions, extensions,
|
|||||||
installed packages, which can be gigabytes. So in-container Pi and Grok sessions are
|
installed packages, which can be gigabytes. So in-container Pi and Grok sessions are
|
||||||
invisible from the host (`pi -c` and `grok -c` inside a docker case see only that
|
invisible from the host (`pi -c` and `grok -c` inside a docker case see only that
|
||||||
container's history). OMP's `sessions/` is the exception and is shared read-write, because
|
container's history). OMP's `sessions/` is the exception and is shared read-write, because
|
||||||
Codeman reads it host-side for history and resume.
|
Codeman reads it host-side for history and resume. jcode's credentials are not seeded at all
|
||||||
|
(`~/.jcode` keeps its OAuth tokens beside its builds, sessions and caches): sign in to jcode
|
||||||
|
inside the container.
|
||||||
|
|
||||||
GitHub Copilot CLI is not seeded: on a host without a keyring its sign-in token sits in plain
|
GitHub Copilot CLI is not seeded: on a host without a keyring its sign-in token sits in plain
|
||||||
text in `~/.copilot/config.json`, and there is no Docker credential row for it. Sign in inside
|
text in `~/.copilot/config.json`, and there is no Docker credential row for it. Sign in inside
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ stores, or refreshes your credentials.
|
|||||||
### Which agent CLIs does it support?
|
### Which agent CLIs does it support?
|
||||||
|
|
||||||
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness,
|
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness,
|
||||||
OMP and GitHub Copilot CLI, plus a plain shell, chosen per session. Claude is the reference mode and a few features
|
OMP, GitHub Copilot CLI and jcode, plus a plain shell, chosen per session. Claude is the reference mode and a few features
|
||||||
are Claude-only; [Agent CLIs](Agent-CLIs) has the table.
|
are Claude-only; [Agent CLIs](Agent-CLIs) has the table.
|
||||||
|
|
||||||
### Does Codeman send my code or prompts anywhere?
|
### Does Codeman send my code or prompts anywhere?
|
||||||
|
|||||||
+4
-3
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
Codeman runs your coding agents on your own machine and puts them behind one dashboard you
|
Codeman runs your coding agents on your own machine and puts them behind one dashboard you
|
||||||
can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi,
|
can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi,
|
||||||
Grok, DeepSeek Harness, OMP, or GitHub Copilot CLI inside persistent tmux sessions, streams the real terminal to the browser, and keeps
|
Grok, DeepSeek Harness, OMP, GitHub Copilot CLI, or jcode inside persistent tmux sessions, streams the real terminal to the browser, and keeps
|
||||||
working while you are away from the keyboard: it re-prompts idle agents, resumes when a
|
working while you are away from the keyboard: it re-prompts idle agents, resumes when a
|
||||||
subscription limit resets, runs jobs on a schedule, and shows every background subagent
|
subscription limit resets, runs jobs on a schedule, and shows every background subagent
|
||||||
live.
|
live.
|
||||||
@@ -33,7 +33,7 @@ codeman web # then open http://localhost:3000
|
|||||||
|
|
||||||
**Already running it**
|
**Already running it**
|
||||||
|
|
||||||
- [Agent CLIs](Agent-CLIs) - the eleven run modes, their setup, and which features are Claude-only.
|
- [Agent CLIs](Agent-CLIs) - the twelve run modes, their setup, and which features are Claude-only.
|
||||||
- [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar.
|
- [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar.
|
||||||
- [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login.
|
- [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login.
|
||||||
- [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits.
|
- [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits.
|
||||||
@@ -66,6 +66,7 @@ codeman web # then open http://localhost:3000
|
|||||||
| [Tile Grid](Tile-Grid) | How do I watch and drive several sessions side by side? |
|
| [Tile Grid](Tile-Grid) | How do I watch and drive several sessions side by side? |
|
||||||
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
|
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
|
||||||
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
|
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
|
||||||
|
| [Local Models](Local-Models) | How do I run a coding model on my own Mac, and use it next to a cloud model? |
|
||||||
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
|
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
|
||||||
| [Git Worktrees](Git-Worktrees) | How do several agents work on one repository without sharing a folder? |
|
| [Git Worktrees](Git-Worktrees) | How do several agents work on one repository without sharing a folder? |
|
||||||
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
|
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
|
||||||
@@ -129,7 +130,7 @@ codeman web # then open http://localhost:3000
|
|||||||
| OS | macOS or Linux. Windows works through WSL2. |
|
| OS | macOS or Linux. Windows works through WSL2. |
|
||||||
| Node.js | 22 or newer. |
|
| Node.js | 22 or newer. |
|
||||||
| tmux | Required. Sessions live in tmux, which is what makes them survive restarts. |
|
| tmux | Required. Sessions live in tmux, which is what makes them survive restarts. |
|
||||||
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP, GitHub Copilot CLI. Plain shell sessions need none. |
|
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP, GitHub Copilot CLI, jcode. Plain shell sessions need none. |
|
||||||
| Network | Binds to `127.0.0.1` by default. Reaching it from another device is a deliberate step: see [Remote Access](Remote-Access). |
|
| Network | Binds to `127.0.0.1` by default. Reaching it from another device is a deliberate step: see [Remote Access](Remote-Access). |
|
||||||
|
|
||||||
Codeman is MIT licensed, self-hosted, and sends no telemetry. Everything runs on your
|
Codeman is MIT licensed, self-hosted, and sends no telemetry. Everything runs on your
|
||||||
|
|||||||
@@ -78,6 +78,26 @@ sequences, so word jumps and history keys keep working.
|
|||||||
| **Deepgram** | A Deepgram API key. | Nova-3, with automatic silence detection. |
|
| **Deepgram** | A Deepgram API key. | Nova-3, with automatic silence detection. |
|
||||||
| **Web Speech** | Nothing. | Browser-provided, quality varies. |
|
| **Web Speech** | Nothing. | Browser-provided, quality varies. |
|
||||||
|
|
||||||
|
### Thinking pauses
|
||||||
|
|
||||||
|
In **App Settings → Voice → Stop after silence**, choose 3, 6, 15 or 30 seconds,
|
||||||
|
or **Manual** to keep listening until you tap the mic again (`Ctrl+Shift+V`).
|
||||||
|
**Provider default** uses 6 seconds for Claude and 3 seconds for Deepgram or Web
|
||||||
|
Speech. Final transcript events no longer stop Deepgram or Web Speech immediately:
|
||||||
|
they wait for the configured silence deadline. The timeout counts from the latest
|
||||||
|
transcript update. A browser can still end its native recognition run; extended
|
||||||
|
and Manual dictation restart it while preserving the text and silence deadline.
|
||||||
|
A silent run that lasts its full length never ends one, so 30 seconds and Manual
|
||||||
|
survive long pauses. Connection failures, microphone errors, or a recognizer that
|
||||||
|
keeps ending within a second of starting without hearing anything can still end
|
||||||
|
a recording.
|
||||||
|
|
||||||
|
On Chrome for Android, live words replace earlier partial results without repeating
|
||||||
|
them in the input. The browser microphone bars indicate speech activity; streaming
|
||||||
|
providers show audio levels. After Stop, dictation briefly waits for the final
|
||||||
|
words before inserting text. Reconnecting preserves the available words and resets
|
||||||
|
the mic so another recording can start.
|
||||||
|
|
||||||
### Dictating through your Claude login
|
### Dictating through your Claude login
|
||||||
|
|
||||||
Off by default; enable it in **App Settings → Voice**.
|
Off by default; enable it in **App Settings → Voice**.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Getting Codeman onto a machine, verifying it works, updating it, and removing it
|
|||||||
| **macOS or Linux** | Windows works through WSL2. See [Windows](#windows-wsl) below. |
|
| **macOS or Linux** | Windows works through WSL2. See [Windows](#windows-wsl) below. |
|
||||||
| **Node.js 22+** | The installer offers to install it if missing. |
|
| **Node.js 22+** | The installer offers to install it if missing. |
|
||||||
| **tmux** | Not optional. Sessions live inside tmux, which is what makes them survive a server restart, a dropped connection, or a closed laptop. |
|
| **tmux** | Not optional. Sessions live inside tmux, which is what makes them survive a server restart, a dropped connection, or a closed laptop. |
|
||||||
| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
|
| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), [jcode](https://jcode.sh). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
|
||||||
|
|
||||||
Codeman itself sends no telemetry and phones no home. The only network traffic is your
|
Codeman itself sends no telemetry and phones no home. The only network traffic is your
|
||||||
browser to your server, and whatever the agent CLI you chose does on its own, plus the
|
browser to your server, and whatever the agent CLI you chose does on its own, plus the
|
||||||
@@ -248,9 +248,19 @@ A small native app that opens Codeman in its own window. Its first screen has tw
|
|||||||
runs the installer on that machine over SSH.
|
runs the installer on that machine over SSH.
|
||||||
|
|
||||||
On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
|
On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
|
||||||
**View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of
|
**View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while
|
||||||
your own for working, for waiting for you and for needing permission. All three are off
|
they work, blinks when one waits for you and flashes three times when one needs permission.
|
||||||
until you choose, and the menu only exists on such a Mac.
|
Each state can take another style under **View > Apple Logo Blink Styles**. It is off until
|
||||||
|
you turn it on, and the menu only exists on such a Mac.
|
||||||
|
|
||||||
|
**Codeman > Keep This Mac Awake** stops the Mac from going to sleep while your agents work
|
||||||
|
on it (**While Agents Work**, plus 5 minutes after the last one stops), or for as long as
|
||||||
|
the app is open (**Always**). It is off until you choose, only acts for a Codeman running
|
||||||
|
on this Mac, needs no password, and ends when the app quits. On a MacBook it pauses on
|
||||||
|
battery unless you untick **Only When Plugged In**, and **With the Lid Closed** keeps a
|
||||||
|
closed MacBook awake too, with no password and never on battery. Codeman 1.50.1 installed
|
||||||
|
a small helper for this behind your administrator password. It is no longer used:
|
||||||
|
**Remove the Old Lid Helper…** in the same menu takes it away.
|
||||||
|
|
||||||
### First launch
|
### First launch
|
||||||
|
|
||||||
@@ -309,6 +319,7 @@ Codeman drives CLIs, it does not bundle them. Install at least one:
|
|||||||
| **Grok Build** | `curl -fsSL https://x.ai/cli/install.sh \| bash` | xAI. Lands in `~/.grok/bin`; `grok login --device-auth` for headless hosts. |
|
| **Grok Build** | `curl -fsSL https://x.ai/cli/install.sh \| bash` | xAI. Lands in `~/.grok/bin`; `grok login --device-auth` for headless hosts. |
|
||||||
| **DeepSeek Harness** | `npm i -g @deepseek-ai/dsh pnpm`, then a terminal profile | The npm package is only a launcher. Codeman's Run menu installs the community terminal profile for you. See [Agent CLIs](Agent-CLIs). |
|
| **DeepSeek Harness** | `npm i -g @deepseek-ai/dsh pnpm`, then a terminal profile | The npm package is only a launcher. Codeman's Run menu installs the community terminal profile for you. See [Agent CLIs](Agent-CLIs). |
|
||||||
| **OMP** | `curl -fsSL https://omp.sh/install \| sh` | Oh My Pi. Run it once by hand to finish its own onboarding. |
|
| **OMP** | `curl -fsSL https://omp.sh/install \| sh` | Oh My Pi. Run it once by hand to finish its own onboarding. |
|
||||||
|
| **jcode** | `curl -fsSL https://jcode.sh/install \| bash` | Run it once by hand to finish (or skip) its onboarding wizard. |
|
||||||
|
|
||||||
Log each CLI in once, by hand, before pointing Codeman at it. Codeman never collects or
|
Log each CLI in once, by hand, before pointing Codeman at it. Codeman never collects or
|
||||||
stores your CLI credentials.
|
stores your CLI credentials.
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Local Models
|
||||||
|
|
||||||
|
Run a coding model on your own Mac, next to the cloud models you already use. Codeman
|
||||||
|
installs the model, keeps it running, and lets you start sessions on it or hand it work from
|
||||||
|
a cloud session. No account and no API key: the model runs only on that Mac.
|
||||||
|
|
||||||
|
This is **experimental**, and it needs a Mac with Apple silicon as the machine the Codeman
|
||||||
|
server runs on. On a machine that is not a Mac the button stays hidden.
|
||||||
|
|
||||||
|
## Install a model
|
||||||
|
|
||||||
|
1. Press **Run a local model** on the welcome screen (or open App Settings > Models > Model
|
||||||
|
on this Mac).
|
||||||
|
2. The window shows which models fit this Mac. The bar at the top is the memory the Mac can
|
||||||
|
give a model, filled to what the selected model needs at its peak. A nearly full bar is a
|
||||||
|
tight fit.
|
||||||
|
3. Pick a model, and under **Runs with** the coding agent to start it with.
|
||||||
|
4. Press **Install on this Mac**. The next screen lists every download with its size and
|
||||||
|
where it goes. Nothing is downloaded before you confirm there.
|
||||||
|
5. Codeman downloads a model runtime and the model into `~/.codeman/local-model`, loads the
|
||||||
|
model, checks that all of it runs on the GPU and that it can call a tool, and installs
|
||||||
|
the coding agent if it is missing. No administrator password is asked for.
|
||||||
|
6. Press **Start a session**.
|
||||||
|
|
||||||
|
You can close the window while it downloads. **Cancel** pauses the install and **Continue**
|
||||||
|
picks it up where it stopped. After a Codeman restart an unfinished install waits for your
|
||||||
|
click.
|
||||||
|
|
||||||
|
## Pick the coding agent
|
||||||
|
|
||||||
|
A model is driven by a coding agent: the program that reads your prompt, calls tools and
|
||||||
|
edits files. You choose it **per session**. Open the window again and look at **Start a
|
||||||
|
session with**: one chip per agent the model can be run with.
|
||||||
|
|
||||||
|
| Agent | Pick it when |
|
||||||
|
| ---------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
|
| OpenCode | You have no reason to choose. It is the pair Codeman was tested with on a Mac |
|
||||||
|
| Pi | You want the first answer soon. Its fixed prompt is the smallest by far, which also leaves the most room for your work |
|
||||||
|
| Claude Code | You want Claude Code's own tools and habits. Its first answer on a local model takes minutes |
|
||||||
|
| DeepSeek Harness | You already use it and have it set up |
|
||||||
|
|
||||||
|
Select a chip and press **Start a session**. The choice is remembered on that device.
|
||||||
|
|
||||||
|
**A chip that says "Not installed"** is an agent Codeman can install for you. Select it: the
|
||||||
|
window shows the exact command it would run, and the main button becomes **Install** with
|
||||||
|
the agent's name. The model stays ready while the installer runs, and you can start a
|
||||||
|
session with another agent meanwhile. If the install fails, the window says so under the
|
||||||
|
chip and nothing else changes.
|
||||||
|
|
||||||
|
**A chip that says "Needs setup"** is an agent that needs more than one command. DeepSeek
|
||||||
|
Harness is the one today: set it up from the **Run** menu first, then it can be started
|
||||||
|
here. A new DeepSeek Harness also opens its own setup guide in every session until you have
|
||||||
|
finished it once.
|
||||||
|
|
||||||
|
## More than one model
|
||||||
|
|
||||||
|
Once a model is installed, the window lists **Models on this Mac** with what each takes on
|
||||||
|
disk, and the total under the list.
|
||||||
|
|
||||||
|
- **Add a model** opens the list of models again and downloads another one. When it is
|
||||||
|
ready it becomes the model in use; the earlier one stays on the Mac.
|
||||||
|
- **Use** switches to a model that is already there. Nothing is downloaded: Codeman loads it
|
||||||
|
and checks it again.
|
||||||
|
- **Delete** removes one model, after one question that says how much space it frees.
|
||||||
|
|
||||||
|
One model runs at a time. Adding and switching are refused while sessions still use the
|
||||||
|
current model: close those sessions first. The model in use cannot be deleted: switch first,
|
||||||
|
or use **Remove**, which takes the runtime and the connection away (and the downloaded
|
||||||
|
models too, if you tick that).
|
||||||
|
|
||||||
|
## Cloud and local together
|
||||||
|
|
||||||
|
The reason to keep a local model next to a cloud one: the cloud model plans and reviews, the
|
||||||
|
Mac does the bulk. From a Claude Code session in Codeman, with the
|
||||||
|
[Codeman skill](Codeman-Skill) (on by default), you can just say it:
|
||||||
|
|
||||||
|
| You say | What happens |
|
||||||
|
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
|
| "Is there a local model on this Mac?" | It reads what is installed, which agents can run it and which models are on the Mac. Read-only |
|
||||||
|
| "Use the local model to summarize every file in `docs/`, then check its summaries." | A worker on the local model does the reading; the cloud session reads its answer back and reviews it |
|
||||||
|
| "Spawn a local worker with Pi to draft tests for `src/parser.ts`." | The worker runs the local model through Pi |
|
||||||
|
| "Start two cloud workers for the API and the UI, and a local one for the changelog." | One mixed fleet: cloud workers side by side, the Mac as a queue |
|
||||||
|
|
||||||
|
The worker sessions appear as tabs in the dashboard and are closed when the work is done.
|
||||||
|
|
||||||
|
Good to know before you rely on it:
|
||||||
|
|
||||||
|
- **One request at a time.** The local model answers one request at a time, so two local
|
||||||
|
workers take turns. Fan out to the cloud, and give the Mac a queue.
|
||||||
|
- **Slower than a cloud model**, and the first request after a start loads the model first.
|
||||||
|
- **Review what comes back.** A local model is good at bulk work and weaker at judgment.
|
||||||
|
Treat its answer as a first draft.
|
||||||
|
- **The skill never installs anything.** A model or an agent that is missing is yours to
|
||||||
|
install in the window; the skill tells you what is missing and stops.
|
||||||
|
- **While a local worker is open**, the window refuses Stop, Remove and a switch of models,
|
||||||
|
and names the sessions that are in the way.
|
||||||
|
|
||||||
|
## Good to know
|
||||||
|
|
||||||
|
- **What was tested.** One pair was run on a real Mac: OpenCode with Qwen3.6 35B-A3B on an
|
||||||
|
M5 Pro with 48 GB. The window marks that pair on a Mac like it. Everything else in the
|
||||||
|
list is calculated from each model's own figures, and the install checks the model on your
|
||||||
|
Mac before it lets you start a session.
|
||||||
|
- **Memory first.** A model that does not fit in memory is not offered. An estimate becomes
|
||||||
|
a measurement the first time the runtime starts, and the model is judged against that
|
||||||
|
before it is downloaded.
|
||||||
|
- **The developer tools.** Coding agents use git and Python. On a Mac without them, macOS
|
||||||
|
offers to install them the first time an agent needs one.
|
||||||
|
- **Stop** keeps the runtime from starting with Codeman next time. **Start** undoes that.
|
||||||
|
- **In multi-user mode** only an admin sees and uses this.
|
||||||
|
|
||||||
|
More detail: [A model that runs on this Mac](Custom-Model-Endpoints#a-model-that-runs-on-this-mac)
|
||||||
|
for how it relates to your own model servers, and
|
||||||
|
[`docs/local-models.md`](https://github.com/Ark0N/Codeman/blob/master/docs/local-models.md)
|
||||||
|
for how it works inside.
|
||||||
@@ -187,16 +187,19 @@ Setup in [Notifications And Approvals](Notifications-And-Approvals).
|
|||||||
## Reading long answers
|
## Reading long answers
|
||||||
|
|
||||||
The terminal viewport is small. **Last Response** (opt-in header button) renders the agent's
|
The terminal viewport is small. **Last Response** (opt-in header button) renders the agent's
|
||||||
last answer as scrollable text instead, with a **More** button for additional context.
|
last answer as scrollable text instead, with a **More** button for additional context and a
|
||||||
Persian, Arabic and Hebrew answers read right to left there.
|
**Copy Markdown** button for its raw source. Persian, Arabic and Hebrew answers read right to
|
||||||
|
left there.
|
||||||
|
|
||||||
The [File Viewer](Working-With-Files) works on phones too, including edit mode, which is
|
The [File Viewer](Working-With-Files) works on phones too, including edit mode, which is
|
||||||
enough to fix a typo an agent introduced while you are away from your desk.
|
enough to fix a typo an agent introduced while you are away from your desk. Its header button
|
||||||
|
is off on a phone until you turn on **File Viewer** under **App Settings → Header & Panels**
|
||||||
|
on that phone.
|
||||||
|
|
||||||
## What is deliberately not on phones
|
## What is deliberately not on phones
|
||||||
|
|
||||||
- Extra header buttons. New header controls are kept off phones by policy, with a test that
|
- Extra header buttons. New header controls are kept off phones by policy, with a test that
|
||||||
enforces it.
|
enforces it. The one exception is File Viewer, which you can switch on (see above).
|
||||||
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
||||||
- The desktop home tab rail, which needs a wide window.
|
- The desktop home tab rail, which needs a wide window.
|
||||||
- Lineage lines, which are a desktop overlay.
|
- Lineage lines, which are a desktop overlay.
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ one:
|
|||||||
| **Grok Build** | xAI's CLI. |
|
| **Grok Build** | xAI's CLI. |
|
||||||
| **DeepSeek Harness** | Needs a terminal profile; the menu offers to install one. |
|
| **DeepSeek Harness** | Needs a terminal profile; the menu offers to install one. |
|
||||||
| **OMP** | Oh My Pi, configured entirely through its own `~/.omp`. |
|
| **OMP** | Oh My Pi, configured entirely through its own `~/.omp`. |
|
||||||
|
| **jcode** | Configured through its own `~/.jcode`; no permission prompts. |
|
||||||
| **Terminal / Shell** | A plain shell, no agent. Also the **Run Shell** button. |
|
| **Terminal / Shell** | A plain shell, no agent. Also the **Run Shell** button. |
|
||||||
|
|
||||||
The dropdown also lists any saved dashboard URLs ([Web Tabs](Web-Tabs)) and your recent
|
The dropdown also lists any saved dashboard URLs ([Web Tabs](Web-Tabs)) and your recent
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Point a case at another machine and the agent runs **there**, with the same dash
|
|||||||
mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the
|
mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the
|
||||||
remote host.
|
remote host.
|
||||||
|
|
||||||
Like Docker, this is a **location overlay** on a case, not a run mode. All eleven run modes
|
Like Docker, this is a **location overlay** on a case, not a run mode. All twelve run modes
|
||||||
work remotely. See [Core Concepts](Core-Concepts).
|
work remotely. See [Core Concepts](Core-Concepts).
|
||||||
|
|
||||||
## Why bother
|
## Why bother
|
||||||
|
|||||||
@@ -247,8 +247,9 @@ Discord group for the two-way bot (token, IDs, thread and ping options; see
|
|||||||
### Voice
|
### Voice
|
||||||
|
|
||||||
Active provider and the engine behind it, insert mode, language, domain keywords to bias
|
Active provider and the engine behind it, insert mode, language, domain keywords to bias
|
||||||
recognition, the Deepgram API key, and the opt-in switch for transcribing through this
|
recognition, **Stop after silence** (provider default, 3, 6, 15 or 30 seconds, or Manual),
|
||||||
server's Claude login, with its live credential status. See
|
the Deepgram API key, and the opt-in switch for transcribing through this server's Claude
|
||||||
|
login, with its live credential status. See
|
||||||
[Input And Voice](Input-And-Voice).
|
[Input And Voice](Input-And-Voice).
|
||||||
|
|
||||||
### Shortcuts
|
### Shortcuts
|
||||||
@@ -258,7 +259,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
|||||||
### System
|
### System
|
||||||
|
|
||||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, the prompt-cache keep-warm default (**Keep prompt caches warm**, off) and its cap (**Keep warm for at most**, 2 hours after a session's last prompt; see [Keeping Agents Running](Keeping-Agents-Running)), and
|
`CLAUDE.md` template for new cases, default working directory, the image watcher, the prompt-cache keep-warm default (**Keep prompt caches warm**, off) and its cap (**Keep warm for at most**, 2 hours after a session's last prompt; see [Keeping Agents Running](Keeping-Agents-Running)), and
|
||||||
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
|
Cloudflare tunnel controls including the tunnel URL. **Sync cases across devices** (under Paths, off by default): Keeps each case's **Agent Teams** and **Opus 1M** options on the server (`caseSettings` in `settings.json`) and, when you return to a tab, switches it to the case you last chose on another device. Synced, off by default. Without it each browser keeps its own options for a case (the case list itself is always shared). The case names are stored in the shared settings file, so a non-admin in multi-user mode neither reads, writes nor follows them. Each save sends only that case and the server merges it, so devices saving different cases never overwrite each other; a case renamed in Codeman keeps its options, and deleting or unlinking it removes them. A save the server refuses leaves the choice on that device only until the next sync, which brings the server's copy back, and says so. Only a case picked in a case picker is followed, not one a quick-start or an agent's spawned worker used. If another device turns this off, this one stops following, and the server ignores case options sent while it is off. Turning it off leaves the stored options in `settings.json`, so turning it back on brings them back. A device that already has options for a case keeps them until a device changes that case's options while this is on. The **Diagnostics** group runs
|
||||||
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||||
mode, the **Users** administration entry is injected here.
|
mode, the **Users** administration entry is injected here.
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ The right side of the header. Almost all of these are off until you enable them
|
|||||||
| Connection dot | Always on | SSE connection health. Green is connected. |
|
| Connection dot | Always on | SSE connection health. Green is connected. |
|
||||||
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
|
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
|
||||||
| CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. |
|
| CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. |
|
||||||
| File Viewer | On | Toggles the file browser panel. |
|
| File Viewer | On, off on phones | Toggles the file browser panel. |
|
||||||
| Settings gear | Always on | App Settings. |
|
| Settings gear | Always on | App Settings. |
|
||||||
| Plan usage chip | On, desktop only | Live subscription usage: Claude (needs its telemetry exporter, which the same setting installs), Codex limits and the Copilot monthly quota. |
|
| Plan usage chip | On, desktop only | Live subscription usage: Claude (needs its telemetry exporter, which the same setting installs), Codex limits and the Copilot monthly quota. |
|
||||||
| Session Manager | Off | The full session list, live and historical. |
|
| Session Manager | Off | The full session list, live and historical. |
|
||||||
|
|||||||
@@ -77,9 +77,11 @@ Turning on either starts the watcher.
|
|||||||
|
|
||||||
**Last Response** (header button, opt-in) renders the agent's last answer as scrollable text
|
**Last Response** (header button, opt-in) renders the agent's last answer as scrollable text
|
||||||
rather than terminal output. It exists mostly for phones, where reading a long answer in a
|
rather than terminal output. It exists mostly for phones, where reading a long answer in a
|
||||||
terminal viewport is painful. **More** loads additional context. Persian, Arabic and Hebrew
|
terminal viewport is painful. **More** loads additional context. **Copy Markdown** copies the
|
||||||
answers read right to left here, which the terminal cannot do, while code and the file paths
|
newest answer's raw Markdown, every part of its turn, so headings, lists and code fences paste
|
||||||
it links stay left to right.
|
intact (the same answer in both views; tool and status blocks are left out). Persian, Arabic
|
||||||
|
and Hebrew answers read right to left here, which the terminal cannot do, while code and the
|
||||||
|
file paths it links stay left to right.
|
||||||
|
|
||||||
## After the fact
|
## After the fact
|
||||||
|
|
||||||
@@ -106,9 +108,9 @@ are not reading.
|
|||||||
|
|
||||||
- **A session pointed at a relocated Claude config directory goes blind here.** Transcripts
|
- **A session pointed at a relocated Claude config directory goes blind here.** Transcripts
|
||||||
written outside `~/.claude/projects` are invisible to the watchers, so subagent windows,
|
written outside `~/.claude/projects` are invisible to the watchers, so subagent windows,
|
||||||
the ultracode panel, the response viewer, and Read My Mind all stop working for that
|
the ultracode panel and Read My Mind stop working for that session. Symlink `projects`
|
||||||
session. Symlink `projects` back into the shared tree to fix it. See
|
back into the shared tree to fix it. The response viewer reads the session's own
|
||||||
[Agent CLIs](Agent-CLIs).
|
directory and keeps working. See [Agent CLIs](Agent-CLIs).
|
||||||
- **Closing a window does not cancel the agent.** Nothing on this page controls agents; it
|
- **Closing a window does not cancel the agent.** Nothing on this page controls agents; it
|
||||||
observes them.
|
observes them.
|
||||||
- **Windows are opt-in for ultracode, automatic for subagents.**
|
- **Windows are opt-in for ultracode, automatic for subagents.**
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ until you get home.
|
|||||||
|
|
||||||
A panel that browses the active session's working directory, or the git worktree its agent
|
A panel that browses the active session's working directory, or the git worktree its agent
|
||||||
moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header
|
moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header
|
||||||
button is on by default; if it is missing, re-enable it in **App Settings → Header & Panels**.
|
button is on by default on a desktop and off on a phone. The setting is per device: switch it
|
||||||
|
in **App Settings → Header & Panels** on the device you want it on.
|
||||||
|
|
||||||
The panel and its search list up to 5,000 files and folders per load, shallow levels first and
|
The panel and its search list up to 5,000 files and folders per load, shallow levels first and
|
||||||
taking turns between folders, so one huge folder cannot use up that limit before the ones after it,
|
taking turns between folders, so one huge folder cannot use up that limit before the ones after it,
|
||||||
@@ -19,6 +20,13 @@ not finish shows **Not fully scanned** rather than looking empty. A folder that
|
|||||||
such as a network mount that went away, is skipped rather than waited on and is marked the same way,
|
such as a network mount that went away, is skipped rather than waited on and is marked the same way,
|
||||||
so a search can miss what is inside it. The panel's Refresh button tries it again.
|
so a search can miss what is inside it. The panel's Refresh button tries it again.
|
||||||
|
|
||||||
|
Each file row has a download arrow and a × that deletes the file after you confirm. Both show
|
||||||
|
when you point at the row, and always on a touch screen. Delete removes one file at a time,
|
||||||
|
never a folder, and refuses a symbolic link (deleting one from here would remove the file it
|
||||||
|
points to), anything under `.git`, and the sensitive files the viewer will not serve. It is not
|
||||||
|
offered for remote (SSH) sessions or for tmux sessions opened from outside Codeman. Deleting
|
||||||
|
from search results takes the row out of the results.
|
||||||
|
|
||||||
It renders what it can:
|
It renders what it can:
|
||||||
|
|
||||||
| Kind | Behaviour |
|
| Kind | Behaviour |
|
||||||
@@ -69,6 +77,11 @@ convenient:
|
|||||||
- **`.git/` is denied outright.** Hooks are executable code, and a corrupted index looks
|
- **`.git/` is denied outright.** Hooks are executable code, and a corrupted index looks
|
||||||
unrecoverable to someone who wanted to fix a typo.
|
unrecoverable to someone who wanted to fix a typo.
|
||||||
- **Non-UTF-8 content is refused**, verified by a round-trip comparison.
|
- **Non-UTF-8 content is refused**, verified by a round-trip comparison.
|
||||||
|
- **Dotenv files are editable, other credential files are not.** `.env`, `.env.local` and
|
||||||
|
`prod.env` open in the editor like any config file, and a save keeps the file's mode (a
|
||||||
|
`0600` file stays `0600`) and, where your account may set it, its group, so a `.env` a web
|
||||||
|
server reads through its group stays readable. `.npmrc`, `credentials.json`, anything under
|
||||||
|
`.ssh/` and Codeman's own `~/.codeman/.env` stay read-only here.
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- [Tile Grid](Tile-Grid)
|
- [Tile Grid](Tile-Grid)
|
||||||
- [Agent CLIs](Agent-CLIs)
|
- [Agent CLIs](Agent-CLIs)
|
||||||
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
||||||
|
- [Local Models](Local-Models)
|
||||||
- [Working With Files](Working-With-Files)
|
- [Working With Files](Working-With-Files)
|
||||||
- [Git Worktrees](Git-Worktrees)
|
- [Git Worktrees](Git-Worktrees)
|
||||||
- [Input And Voice](Input-And-Voice)
|
- [Input And Voice](Input-And-Voice)
|
||||||
|
|||||||
+13
-13
@@ -224,19 +224,19 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
|
|||||||
# script will ever execute, and they arrive embedded in this file — same TLS fetch, same
|
# script will ever execute, and they arrive embedded in this file — same TLS fetch, same
|
||||||
# commit as the script itself. Nothing fetched at install time is ever executed; there is
|
# commit as the script itself. Nothing fetched at install time is ever executed; there is
|
||||||
# no network refresh of these arrays. See cli_catalog_select_platform below.
|
# no network refresh of these arrays. See cli_catalog_select_platform below.
|
||||||
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp' 'copilot')
|
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp' 'copilot' 'jcode')
|
||||||
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'GitHub Copilot')
|
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'GitHub Copilot' 'jcode')
|
||||||
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1 1)
|
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1 1 1)
|
||||||
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0 0)
|
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0 0 0)
|
||||||
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh' 'https://github.com/github/copilot-cli')
|
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh' 'https://github.com/github/copilot-cli' 'https://jcode.sh/docs')
|
||||||
CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh' 'npm install -g @github/copilot')
|
CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh' 'npm install -g @github/copilot' 'curl -fsSL https://jcode.sh/install | bash')
|
||||||
CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'brew install can1357/tap/omp' 'npm install -g @github/copilot')
|
CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'brew install can1357/tap/omp' 'npm install -g @github/copilot' 'curl -fsSL https://jcode.sh/install | bash')
|
||||||
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'copilot')
|
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'copilot' 'jcode')
|
||||||
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9)
|
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9 10)
|
||||||
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1 1)
|
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1 1 1)
|
||||||
CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp" "$HOME/.local/bin/copilot" "/usr/local/bin/copilot" "$HOME/.bun/bin/copilot" "$HOME/.npm-global/bin/copilot" "$HOME/bin/copilot")
|
CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp" "$HOME/.local/bin/copilot" "/usr/local/bin/copilot" "$HOME/.bun/bin/copilot" "$HOME/.npm-global/bin/copilot" "$HOME/bin/copilot" "$HOME/.local/bin/jcode" "/usr/local/bin/jcode" "$HOME/bin/jcode")
|
||||||
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47)
|
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47 52)
|
||||||
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5)
|
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5 3)
|
||||||
# <<< END GENERATED CLI CATALOGUE
|
# <<< END GENERATED CLI CATALOGUE
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
Generated
+2
-10
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.50.0",
|
"version": "1.51.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -52,7 +52,6 @@
|
|||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
@@ -3966,13 +3965,6 @@
|
|||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/uuid": {
|
|
||||||
"version": "10.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
|
||||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/web-push": {
|
"node_modules/@types/web-push": {
|
||||||
"version": "3.6.4",
|
"version": "3.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.50.0",
|
"version": "1.51.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.",
|
"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",
|
"mcpName": "io.github.Ark0N/codeman",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
|
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
|
||||||
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
||||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||||
|
"check:frontend-dead": "node scripts/check-frontend-dead-code.mjs",
|
||||||
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
|
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
|
||||||
"check:local-model-catalog": "tsx scripts/check-local-model-catalog.mjs",
|
"check:local-model-catalog": "tsx scripts/check-local-model-catalog.mjs",
|
||||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||||
@@ -123,7 +124,6 @@
|
|||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
|||||||
"NSGeometry",
|
"NSGeometry",
|
||||||
"NSObject",
|
"NSObject",
|
||||||
"NSOperation",
|
"NSOperation",
|
||||||
|
"NSProcessInfo",
|
||||||
"NSRange",
|
"NSRange",
|
||||||
"NSSet",
|
"NSSet",
|
||||||
"NSString",
|
"NSString",
|
||||||
|
|||||||
+195
-35
@@ -40,7 +40,8 @@ cannot do.
|
|||||||
| **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. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
|
| **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. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (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. |
|
| **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. |
|
||||||
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
|
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
|
||||||
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
|
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. LogoLight (1.3 or later) steps aside while Codeman shows its agents on the logo, and takes the logo back afterwards, with whatever it showed before; while an older LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks. With the lid closed it just goes on while the Mac stays awake on its power adapter (**Keep This Mac Awake > With the Lid Closed**, or an external display): macOS keeps the logo dark under a closed lid, so the app tells the logo the lid is open and the keyboard that it is closed, which stays dark, as LogoLight's *Lit with Lid Closed* does. The light sensor is covered then, so the logo glows as it would in a dark room. The line under **Apple Logo Blink Styles** says what the closed lid did last (blinking, or why not). On battery, and while the display only sleeps with the lid open, the blink pauses and writes nothing. |
|
||||||
|
| **Keep awake** | **Codeman > Keep This Mac Awake** keeps the Mac from going to sleep while Codeman's agents work on it: **While Agents Work** (an agent at work, and for 5 minutes after the last one stopped, so the gap between two turns or a reply from your phone does not let it drop off), or **Always** (while the app is open). Off until you choose. Only for a Codeman on this Mac (`127.0.0.1`, `localhost`, or one of this Mac's own addresses); for a server on another machine the menu says so. The display still sleeps. No password is needed anywhere: the app holds a power assertion of its own, which macOS shows in Activity Monitor and in `pmset -g assertions` as *Codeman keeps this Mac awake while its agents work*, and which ends with the app, a crash or a Force Quit included. On a MacBook, **Only When Plugged In** (on by default) pauses it on battery, and **With the Lid Closed** keeps a closed MacBook awake as well: while it holds, the app has macOS's sleep-on-lid-close turned off (see Decisions), and turns it back on when it lets go, quits or crashes. The lid is never kept awake on battery, whatever the settings: unplugged with the lid closed, the Mac goes to sleep. A line at the bottom of the menu says what it does right now: whether the lid is covered, that it is left to macOS while an external display is connected (a docked MacBook stays awake with its lid closed by macOS's own doing), and if the Mac slept under the lid all the same, until when. Codeman 1.50.1 kept the lid awake through a small root helper, behind the administrator password. Nothing uses that helper any more, and where it is still installed the menu has **Remove the Old Lid Helper…** (the one item that asks for the password). It lives in the app, so it ends when the app quits: Codeman keeps running as a LaunchAgent, but nothing keeps the Mac awake then. That is why, while it watches a Codeman on this Mac, closing the Codeman window hides the app instead of quitting it, as Cmd+H does, also while it is paused (on battery, say), since it carries on by itself: a click on its Dock icon brings it back, and only Cmd+Q (or Quit from the Dock) ends it. With no server open, or one on another machine, closing the window quits. |
|
||||||
| **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. |
|
| **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)
|
## Build (on a Mac)
|
||||||
@@ -147,6 +148,68 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
|
|||||||
- `codeman web -d` gets the port and base path as separate arguments, never as
|
- `codeman web -d` gets the port and base path as separate arguments, never as
|
||||||
shell text.
|
shell text.
|
||||||
|
|
||||||
|
**Keeping a closed MacBook awake is one kernel switch, held by a child process.**
|
||||||
|
macOS sleeps on lid close whatever an app asks: `caffeinate` and every power
|
||||||
|
assertion stop idle sleep only, and an assertion against system sleep turns the
|
||||||
|
lid's sleep into a dark wake. What decides is the power management root's
|
||||||
|
lid-sleep switch (`kPMSetClamshellSleepState` on `IOPMrootDomain`), which any
|
||||||
|
process may set through the root domain's user client, with no entitlement and
|
||||||
|
no password. With it set a closed lid changes nothing and the Mac stays at full
|
||||||
|
wake. It is the method LogoLight ships as *Lit with Lid Closed*; 1.50.1 used
|
||||||
|
`pmset -a disablesleep 1` through a root LaunchDaemon instead.
|
||||||
|
|
||||||
|
The switch has no owner (read from Apple's open sources of the kernel and of
|
||||||
|
its power daemon, `IOPMrootDomain.cpp` and `PMAssertions.c`). The kernel keeps
|
||||||
|
it after the process that set it has gone, and honours it on battery. It is a
|
||||||
|
single bit that the power daemon also writes when its own reasons change, and
|
||||||
|
that LogoLight writes for its feature. On an Apple silicon Mac it is also what
|
||||||
|
keeps a docked MacBook awake with its lid closed: the window server tells the
|
||||||
|
power daemon about the external display, and the daemon sets the bit once, when
|
||||||
|
that changes, so a zero written by anyone else stays until the display or the
|
||||||
|
adapter next changes. Clearing it under a closed lid puts the Mac to sleep at
|
||||||
|
once. So the app never writes it itself. A child process (`src/lid_awake.rs`, this binary with
|
||||||
|
`--keep-awake-lid`) holds it together with two assertions, against system sleep
|
||||||
|
and idle sleep (LogoLight measured the three together), and:
|
||||||
|
|
||||||
|
- sets the switch again every second, so a write by anyone else is undone
|
||||||
|
within a second;
|
||||||
|
- reads the power source itself and holds nothing on battery, whatever the app
|
||||||
|
says;
|
||||||
|
- writes nothing at all while an external display is connected: macOS's own
|
||||||
|
closed-lid mode has the switch then, and a zero from Codeman would put a
|
||||||
|
docked Mac to sleep, or leave it without that mode until the display is
|
||||||
|
plugged in again. The assertions keep the Mac awake meanwhile. What the child
|
||||||
|
set before the display came is cleared once the display has been gone for five
|
||||||
|
seconds (the display list is short for a moment while displays are
|
||||||
|
rearranged), and for that the child outlives the app if it has to;
|
||||||
|
- lets go when the pipe from the app closes (a quit, a crash, a Force Quit) or
|
||||||
|
when the app has not written to it for a minute (a hang): the assertions
|
||||||
|
first, then the switch, so a closed Mac goes straight to sleep;
|
||||||
|
- ignores SIGHUP, SIGINT and SIGTERM, which arrive together with the app's own
|
||||||
|
end, so the pipe is its one way out;
|
||||||
|
- leaves the switch alone on its way out while LogoLight holds it for its own
|
||||||
|
feature (LogoLight's power assertions say so): LogoLight sets it once and
|
||||||
|
would not notice it gone.
|
||||||
|
|
||||||
|
One child at a time holds a lock on `lid-awake` next to `desktop.json`, and
|
||||||
|
that file is non-empty while the switch may be set. A child that was killed
|
||||||
|
leaves it marked and unlocked, and the app turns lid sleep back on when it sees
|
||||||
|
a child end and at its next launch. The child tells the app what it did (the
|
||||||
|
kernel took the call, a display is connected so nothing was written, on
|
||||||
|
battery, refused, or that the Mac slept all the same, which it sees as
|
||||||
|
wall-clock time that passed while its monotonic clock stood still), and the
|
||||||
|
menu's status line says it.
|
||||||
|
|
||||||
|
The helper of 1.50.1 gets nothing to act on: its request file is never written
|
||||||
|
and is deleted at launch. It stays installed until the user removes it from
|
||||||
|
the menu, the one thing left behind macOS's administrator prompt (`do shell
|
||||||
|
script ... with administrator privileges` through `osascript`, so the event
|
||||||
|
loop keeps running while the prompt is open). The idle-sleep part needs none of
|
||||||
|
this: an `NSProcessInfo` activity with `IdleSystemSleepDisabled`, released by
|
||||||
|
the kernel with the process. While the app watches for work but holds nothing,
|
||||||
|
a second kind of activity keeps App Nap from slowing its look at the server,
|
||||||
|
and still allows sleep.
|
||||||
|
|
||||||
**The app runs the installer itself; Terminal is the fallback.** `install.sh
|
**The app runs the installer itself; Terminal is the fallback.** `install.sh
|
||||||
--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs
|
--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs
|
||||||
no password and therefore no terminal, and the two questions only a person can
|
no password and therefore no terminal, and the two questions only a person can
|
||||||
@@ -225,31 +288,93 @@ 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
|
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
|
WebKit has a cost here: after a server restart, each request that arrives
|
||||||
without a cookie gets a 401 before the
|
without a cookie gets a 401 before the
|
||||||
login goes out, and those count until one succeeds. Many windows reconnecting
|
login goes out, and those count until on**The logo moves on its own hardware fades, from a child process, through CoreBrightness.**
|
||||||
at once can push a request past 10, and that request then fails with a 429
|
Apple documents one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
|
||||||
instead of being answered. A refusal that lands while you are in another app
|
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`, an on/off setting that
|
||||||
does not bring Codeman forward: the connect screen loads where the window is,
|
`corebrightnessd` keeps in root's preferences. A process of the logged-in user can reach the
|
||||||
and the Dock icon bounces.
|
daemon without an administrator through `BrightnessSystemClient` in the private CoreBrightness
|
||||||
|
framework, and 1.50.0 blinked by switching that setting there. The blink now uses two other
|
||||||
|
properties of the same client, which [LogoLight](https://github.com/Ark0N/LogoLight) (MIT, its
|
||||||
|
`docs/corebrightnessd.md`) measured on the real logo and `src/logo.rs` reimplements:
|
||||||
|
`LEPWMFactorFadeDuration` sets how long the next fade takes, and `DisplayLogicalState` 1
|
||||||
|
(Blank) fades the logo to dark and 2 (Unblank) back to lit, on the LED's own spring curve.
|
||||||
|
Nothing of that is saved, so a blink writes no setting and leaves nothing behind a restart;
|
||||||
|
`LEBacklightMode` is only read.
|
||||||
|
|
||||||
**The logo blinks from a child process, through CoreBrightness.** Apple documents
|
The daemon decides the rest. Writing the direction the logo already heads in is ignored, and
|
||||||
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
|
the state cannot be read back, so the child keeps track of it and, when it does not know,
|
||||||
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
|
first turns the logo round with a fade too slow to see. The keyboard backlight follows
|
||||||
for every blink would need a root helper and an administrator password. The same
|
`DisplayLogicalState` too, so after every Blank the keyboards alone are told Unblank
|
||||||
setting is also a property of `BrightnessSystemClient` in the private
|
(`setProperty:withKey:keyboardID:`), only while the built-in display is awake
|
||||||
CoreBrightness framework, and a process of the logged-in user can set it there,
|
(`CGDisplayIsAsleep`); when it sleeps they are told Blank, and Unblank when it wakes. A rise
|
||||||
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
|
from exactly dark waits for the display to show a frame, up to half a second with nothing
|
||||||
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
|
changing on screen. So the short dark beats (Alarm's, Heartbeat's, Wink's, Fast's) turn round
|
||||||
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
|
while their fall is still under way and never quite reach dark, and a long one (Calm's,
|
||||||
keeps its value while the logo is dark.
|
Slow's, the breath's rest) times its light from when the rise starts.
|
||||||
|
|
||||||
The setting is system-wide and survives a reboot, so the app must not end with the
|
With the lid closed the daemon tells every module so (`ClamshellUpdate` 1), and the logo's
|
||||||
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
|
target is then 0 whatever `DisplayLogicalState` says. A client's `ClamshellUpdate` reaches the
|
||||||
app can be relied on. So all of it runs as this same binary with
|
same modules, and LogoLight measured on the real MacBook what follows from that, first with an
|
||||||
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
|
external display and, for its 1.3.1, on a Mac kept awake with none:
|
||||||
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
|
- Kept awake with the lid closed and no other display, the built-in display stays online and
|
||||||
back and exits. Switching it off closes the same pipe, so the path a crash
|
`CGDisplayIsAsleep` says 0. So the lid sensor (`AppleClamshellState` on `IOPMrootDomain`)
|
||||||
takes is the one used every day. A marker file
|
decides that the logo is out of sight, not the display list.
|
||||||
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
|
- Once the daemon has handled the lid (`CBClamshellState` reads 1, a moment after the sensor),
|
||||||
|
`ClamshellUpdate` 0 relights the logo and fades work as with the lid open. The child then
|
||||||
|
writes `ClamshellUpdate` 1 to each keyboard alone, which stays dark, and to each display in
|
||||||
|
`CBDisplayList` alone (`setProperty:withKey:andDisplay:`): told the lid is open, True Tone
|
||||||
|
adapts an external display to the covered light sensor and tints it warm and dim.
|
||||||
|
- The daemon sends the real lid state again when a display or a keyboard comes up. Every beat
|
||||||
|
ends lit, so a logo found dark at the start of the next one is told again.
|
||||||
|
- `DisplayPowerStateEvent` is never written. A client's DidSleep keeps the logo dark, with the
|
||||||
|
lid open too, until someone writes DidWake.
|
||||||
|
|
||||||
|
The child does this only on the power adapter (on battery a closed MacBook sleeps, and a dark
|
||||||
|
wake must not light the logo in a bag) and only with every keyboard reachable. The first rise
|
||||||
|
has to move the logo within 1.5 s. If it does not, the logo is told again, three times in a
|
||||||
|
row and then every half minute, and the menu line says it stayed dark. Ending a blink with the
|
||||||
|
lid closed hands the logo back to normal (the automatic duration, then Unblank, which a held
|
||||||
|
level needs) and then writes `ClamshellUpdate` 1, since normal for a closed lid is dark; the
|
||||||
|
marker covers a child killed in between. When the lid opens, the daemon tells every module
|
||||||
|
itself and nothing is written. What the closed lid did last (blinking, stayed dark, nothing to
|
||||||
|
show with no agent at work, not tried and why, or that the Mac went to sleep under it, which
|
||||||
|
the child sees as wall-clock time that passed while its monotonic clock stood still) goes to a
|
||||||
|
file next to `desktop.json` (`logo-lid`, with the readings behind it on its second line), and
|
||||||
|
the app shows its first line as **Lid Closed: …** under View > Apple Logo Blink Styles, or
|
||||||
|
says nothing watches the lid while no child runs.
|
||||||
|
|
||||||
|
Whether the Mac stays awake under the lid is not the logo's business: Keep This Mac Awake's
|
||||||
|
lid keeper does that (lid sleep off in the kernel, which holds the Mac at full wake), or
|
||||||
|
LogoLight's own Lit with Lid Closed, which is the same switch, or an external display. An assertion against system sleep alone would
|
||||||
|
not do: LogoLight saw the Mac enter a dark wake with it, where the logo stays dark.
|
||||||
|
|
||||||
|
LogoLight drives the logo the same way, so the two would fight over every fade. The marker the
|
||||||
|
child keeps next to the app's settings while it moves the logo (`logo-fade`, which also lets the
|
||||||
|
next launch put back a logo a killed child left dark) holds the child's process ID, goes down
|
||||||
|
right before the first write and comes off once the logo is back to normal. A LogoLight that says
|
||||||
|
`LogoLightStepsAsideForCodeman` in its Info.plist (read from the running app's bundle, by its
|
||||||
|
process ID) looks at that file every second and pauses while it names a running
|
||||||
|
`codeman-desktop`, so the child waits two seconds after writing it before its first fade.
|
||||||
|
LogoLight takes the logo back only once the file has been gone for a second and a half, so a
|
||||||
|
blink that starts within a second of the last one's end (work, then a permission) does not
|
||||||
|
wait again. An older LogoLight is left the logo, as before. LogoLight ships its side since
|
||||||
|
1.3.0. With the lid closed, the two tell the logo the same thing (the lid is open), so a
|
||||||
|
LogoLight that lights the logo under the lid and a blink do not undo each other; when a blink
|
||||||
|
ends there the logo goes dark, and LogoLight lights it again as it takes it back.
|
||||||
|
|
||||||
|
The app must not end with the logo dark, and the release profile has `panic = "abort"`, so no
|
||||||
|
cleanup code of the app can be relied on. So all of it runs as this same binary with
|
||||||
|
`--logo-blink`, for as long as the switch is on and a server is open, tied to the app by
|
||||||
|
nothing but a pipe: when the pipe closes, however the app ended, it puts the logo back (lit,
|
||||||
|
with the daemon's automatic fade duration) and exits. Switching it off closes the same pipe,
|
||||||
|
so the path a crash takes is the one used every day. A marker file (`logo-fade`, next to
|
||||||
|
`desktop.json`) covers what the child cannot survive itself: at the next launch the app puts
|
||||||
|
back a logo a blink left dark, and with 1.50.0's marker (`logo-blink`) it switches back on a
|
||||||
|
logo that version left off. The framework is loaded at run time and never linked, and
|
||||||
|
`LEBacklightMode` reading nil (a Mac mini answers nil) is what hides the option, so a macOS
|
||||||
|
that drops it loses this feature and nothing else.
|
||||||
|
|
||||||
|
e child cannot survive itself:
|
||||||
at the next launch the app puts back a logo a blink left dark. The framework is
|
at the next launch the app puts back a logo a blink left dark. The framework is
|
||||||
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
|
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
|
||||||
answers nil) is what hides the option, so a macOS that drops it loses this feature
|
answers nil) is what hides the option, so a macOS that drops it loses this feature
|
||||||
@@ -308,17 +433,52 @@ crosses, so the login is never on a command line.
|
|||||||
look as a failed login, like any look at a server the app has no login for.
|
look as a failed login, like any look at a server the app has no login for.
|
||||||
*Check again* asks again.
|
*Check again* asks again.
|
||||||
- **The logo blink rests on a private macOS interface.** Apple documents the
|
- **The logo blink rests on a private macOS interface.** Apple documents the
|
||||||
setting, not the way the app reaches it, so a macOS update can take it away;
|
on/off setting, not the fades the app uses, so a macOS update can take them
|
||||||
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
|
away. The menu disappears if `LEBacklightMode` goes; if only the fade keys
|
||||||
from about once every two seconds (*Slow*, *Wink*) to about three times a
|
change, the logo stops moving. A long dark beat (*Calm*, *Slow*, the rest in
|
||||||
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
|
*Breathe*) can be up to half a second longer than its style says, while the
|
||||||
setting is on or off, so these styles are rhythms of hard changes. After a
|
display waits for a frame before the logo rises. Every Blank dims the
|
||||||
blink the key stays there rather than being absent as on a fresh Mac. That
|
keyboard backlight a little before it is told to stay lit. A child killed
|
||||||
reads the same to macOS; `sudo defaults delete com.apple.CoreBrightness
|
outright (SIGKILL) leaves the logo where it was, perhaps dark, until the
|
||||||
LEBacklightMode` removes it. After a power cut in a
|
display next sleeps and wakes or the app next starts. Another app that drives
|
||||||
dark phase the logo stays dark until the app next starts. `api/sessions` does
|
the logo the same way and is not LogoLight is not recognised, and the two
|
||||||
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
|
fight. The handover with a LogoLight that steps aside was checked against a
|
||||||
keep-alive turn blinks too.
|
stand-in process carrying LogoLight 1.3.1's Info.plist and against LogoLight's
|
||||||
|
own tests, and ran in that release candidate next to LogoLight 1.3.1; its
|
||||||
|
timing on a real logo was not measured. `api/sessions` does not say which turns are Codeman's own, so with
|
||||||
|
prompt-cache keep-warm on, a keep-alive turn blinks too.
|
||||||
|
- **Keeping the lid awake without a password has not been seen on a real closed
|
||||||
|
MacBook yet.** The method is LogoLight's, which its author saw work on a
|
||||||
|
MacBook Pro (macOS 27.2, LogoLight 1.3.1). Codeman's own code ran on a Mac
|
||||||
|
mini, which has no lid (macOS 27.2): the kernel took the call from an
|
||||||
|
unprivileged process, the child held its two assertions under its name, let
|
||||||
|
go when its pipe closed and after a minute of silence, ignored SIGTERM, and
|
||||||
|
waited for a child before it; a child killed with SIGKILL was replaced by the
|
||||||
|
app, and its mark undone; told that a display is connected, it held its
|
||||||
|
assertions and wrote nothing. Not checked: *Sleep* in the Apple menu while the lid
|
||||||
|
is held, a run of many hours, and pulling the adapter with the lid closed.
|
||||||
|
Read from Apple's sources and not measured: when an external display is
|
||||||
|
unplugged under a closed lid, macOS's power daemon turns lid sleep back on
|
||||||
|
itself, and the Mac sleeps or drops to a dark wake (logo dark), although the
|
||||||
|
child sets the switch again a second later. Docked is read as "a display other
|
||||||
|
than the built-in one is connected", the nearest an app gets to what the
|
||||||
|
window server tells the power daemon: with a display macOS does not use for
|
||||||
|
its closed-lid mode (Sidecar and AirPlay were not checked), nobody holds the
|
||||||
|
lid, and the Mac sleeps or drops to a dark wake when it closes; the status
|
||||||
|
line then reads "lid left to macOS at a display", and after such a sleep
|
||||||
|
until when the Mac slept. A child that
|
||||||
|
set the switch and was then let go of at a display stays, without the app,
|
||||||
|
until the display has gone. A SIGKILL of the app together with its child
|
||||||
|
leaves lid sleep off until Codeman next starts or the Mac restarts. With two
|
||||||
|
copies of the app open, the lid is released for a second when the copy that
|
||||||
|
held it lets go.
|
||||||
|
- **Blinking with the lid closed was seen on one MacBook Pro so far** (macOS
|
||||||
|
27.2, on power, a release candidate of 1.50.1 on 2026-11-10; the method is
|
||||||
|
LogoLight's, which its author measured for LogoLight 1.3.1). Nothing was
|
||||||
|
measured in that run, it was watched. Not checked, by LogoLight or here: the
|
||||||
|
built-in display's brightness when the lid opens again, and an Apple display
|
||||||
|
with its own light sensor. If the logo does not move under a closed lid, the
|
||||||
|
menu line says "stayed dark".
|
||||||
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
|
- **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
|
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
|
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
|
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
|
||||||
//! never go here; they live in the Keychain (see `credentials`).
|
//! never go here; they live in the Keychain (see `credentials`).
|
||||||
|
|
||||||
|
use crate::keep_awake;
|
||||||
use crate::logo::BlinkStyles;
|
use crate::logo::BlinkStyles;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -44,11 +45,16 @@ pub struct Config {
|
|||||||
pub auto_connect: bool,
|
pub auto_connect: bool,
|
||||||
/// Always open in full screen (otherwise the last state is restored).
|
/// Always open in full screen (otherwise the last state is restored).
|
||||||
pub start_fullscreen: bool,
|
pub start_fullscreen: bool,
|
||||||
/// What the lit Apple logo does for each state of the agents (logo.rs). The
|
/// Whether the lit Apple logo shows what the agents are doing, and its style
|
||||||
/// menu only shows on a Mac that has one. A value this version does not know
|
/// for each state (logo.rs). The menu only shows on a Mac that has one. A
|
||||||
/// reads as off: a bad style must not make the whole file unreadable.
|
/// value this version does not know reads as off: a bad style must not make
|
||||||
|
/// the whole file unreadable.
|
||||||
#[serde(deserialize_with = "lenient_blink")]
|
#[serde(deserialize_with = "lenient_blink")]
|
||||||
pub logo_blink: BlinkStyles,
|
pub logo_blink: BlinkStyles,
|
||||||
|
/// Keep This Mac Awake (keep_awake.rs): when, only on the power adapter, and
|
||||||
|
/// with the lid closed too. Read field by field, like `logo_blink`.
|
||||||
|
#[serde(deserialize_with = "lenient_keep_awake")]
|
||||||
|
pub keep_awake: keep_awake::Settings,
|
||||||
/// Anything that is not a whole frame reads as None: a bad frame must not
|
/// Anything that is not a whole frame reads as None: a bad frame must not
|
||||||
/// make the whole file unreadable, which would cost the server list.
|
/// make the whole file unreadable, which would cost the server list.
|
||||||
#[serde(deserialize_with = "lenient_window")]
|
#[serde(deserialize_with = "lenient_window")]
|
||||||
@@ -64,6 +70,10 @@ fn lenient_blink<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlinkStyles,
|
|||||||
Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?))
|
Ok(BlinkStyles::read(&serde_json::Value::deserialize(d)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lenient_keep_awake<'de, D: serde::Deserializer<'de>>(d: D) -> Result<keep_awake::Settings, D::Error> {
|
||||||
|
Ok(keep_awake::Settings::read(&serde_json::Value::deserialize(d)?))
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Config {
|
Config {
|
||||||
@@ -71,6 +81,7 @@ impl Default for Config {
|
|||||||
auto_connect: true,
|
auto_connect: true,
|
||||||
start_fullscreen: false,
|
start_fullscreen: false,
|
||||||
logo_blink: BlinkStyles::default(),
|
logo_blink: BlinkStyles::default(),
|
||||||
|
keep_awake: keep_awake::Settings::default(),
|
||||||
window: None,
|
window: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,9 +426,12 @@ mod tests {
|
|||||||
use crate::logo::BlinkStyle;
|
use crate::logo::BlinkStyle;
|
||||||
let mut c = Config::default();
|
let mut c = Config::default();
|
||||||
assert_eq!(c.logo_blink, BlinkStyles::default());
|
assert_eq!(c.logo_blink, BlinkStyles::default());
|
||||||
c.logo_blink.needs_you = BlinkStyle::Alarm;
|
assert!(!c.logo_blink.enabled, "off until the user turns it on");
|
||||||
|
c.logo_blink.enabled = true;
|
||||||
|
c.logo_blink.needs_you = BlinkStyle::Wink;
|
||||||
let text = serde_json::to_string(&c).unwrap();
|
let text = serde_json::to_string(&c).unwrap();
|
||||||
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
|
assert!(text.contains(r#""needsYou":"wink""#), "{text}");
|
||||||
|
assert!(text.contains(r#""enabled":true"#), "{text}");
|
||||||
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
|
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
|
||||||
for bad in [
|
for bad in [
|
||||||
r#"{"logoBlink":3}"#,
|
r#"{"logoBlink":3}"#,
|
||||||
@@ -429,11 +443,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keep_awake_round_trips_and_a_bad_value_never_costs_the_file() {
|
||||||
|
use crate::keep_awake::{Mode, Settings};
|
||||||
|
let mut c = Config::default();
|
||||||
|
assert_eq!(c.keep_awake, Settings::default());
|
||||||
|
c.keep_awake.mode = Mode::WhileWorking;
|
||||||
|
c.keep_awake.lid = true;
|
||||||
|
let text = serde_json::to_string(&c).unwrap();
|
||||||
|
assert!(
|
||||||
|
text.contains(r#""keepAwake":{"mode":"whileWorking","acOnly":true,"lid":true}"#),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().keep_awake, c.keep_awake);
|
||||||
|
for bad in [
|
||||||
|
r#"{"keepAwake":3}"#,
|
||||||
|
r#"{"keepAwake":null}"#,
|
||||||
|
r#"{"keepAwake":"always"}"#,
|
||||||
|
] {
|
||||||
|
let c: Config = serde_json::from_str(bad).unwrap();
|
||||||
|
assert_eq!(c.keep_awake, Settings::default(), "{bad}");
|
||||||
|
}
|
||||||
|
// A file from before the setting: every other field still reads.
|
||||||
|
let c: Config = serde_json::from_str(r#"{"startFullscreen":true}"#).unwrap();
|
||||||
|
assert!(c.start_fullscreen);
|
||||||
|
assert_eq!(c.keep_awake, Settings::default());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
|
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
|
||||||
use crate::logo::BlinkStyle;
|
use crate::logo::BlinkStyle;
|
||||||
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
|
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
|
||||||
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
|
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
|
||||||
|
assert!(c.logo_blink.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+2681
-360
File diff suppressed because it is too large.
Load diff
@@ -222,6 +222,13 @@ pub fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
|
|||||||
imp::content_size(window)
|
imp::content_size(window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An app-modal message with an OK button, for something the user asked the app
|
||||||
|
/// itself to do that failed. Main thread only; does nothing elsewhere.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn show_message(title: &str, detail: &str) {
|
||||||
|
imp::show_message(title, detail);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod imp {
|
mod imp {
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
@@ -456,6 +463,17 @@ mod imp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn show_message(title: &str, detail: &str) {
|
||||||
|
let Some(mtm) = MainThreadMarker::new() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let alert = NSAlert::new(mtm);
|
||||||
|
alert.setMessageText(&NSString::from_str(title));
|
||||||
|
alert.setInformativeText(&NSString::from_str(detail));
|
||||||
|
let _ = alert.addButtonWithTitle(&NSString::from_str("OK"));
|
||||||
|
alert.runModal();
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
|
pub(super) fn content_size(window: &tao::window::Window) -> Option<(f64, f64)> {
|
||||||
// NSWindow is main-thread only.
|
// NSWindow is main-thread only.
|
||||||
MainThreadMarker::new()?;
|
MainThreadMarker::new()?;
|
||||||
|
|||||||
+249
-24
@@ -27,6 +27,10 @@ mod assets;
|
|||||||
mod config;
|
mod config;
|
||||||
mod credentials;
|
mod credentials;
|
||||||
mod gui_install;
|
mod gui_install;
|
||||||
|
mod keep_awake;
|
||||||
|
// The pure half is only used by the macOS half and the tests.
|
||||||
|
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||||
|
mod lid_awake;
|
||||||
mod local_server;
|
mod local_server;
|
||||||
mod login;
|
mod login;
|
||||||
mod logo;
|
mod logo;
|
||||||
@@ -110,6 +114,7 @@ enter it again (a single-user Codeman accepts the right password at once), or wa
|
|||||||
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
|
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
|
||||||
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
|
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
|
||||||
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
|
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
|
||||||
|
const LOGO_DRIVEN_ELSEWHERE: &str = "An older LogoLight is driving the Apple logo, so Codeman leaves it alone. Update LogoLight to 1.3 or later (or quit it) to see your agents on it.";
|
||||||
/// What a download's toast calls a file whose name is not known.
|
/// What a download's toast calls a file whose name is not known.
|
||||||
const UNNAMED_FILE: &str = "the file";
|
const UNNAMED_FILE: &str = "the file";
|
||||||
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
|
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
|
||||||
@@ -182,6 +187,17 @@ enum UserEvent {
|
|||||||
/// install opened in Terminal, the install command copied): the message
|
/// install opened in Terminal, the install command copied): the message
|
||||||
/// goes to the page if it is still the one choosing a server.
|
/// goes to the page if it is still the one choosing a server.
|
||||||
ToConnectPage(serde_json::Value),
|
ToConnectPage(serde_json::Value),
|
||||||
|
/// The keep-awake worker's status line, for the value numbered `generation`
|
||||||
|
/// (keep_awake::KeepAwake::is_current).
|
||||||
|
KeepAwakeStatus {
|
||||||
|
generation: u64,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// The administrator prompt for removing the lid helper of 1.50.1 was answered,
|
||||||
|
/// and its command ran (keep_awake::remove_old_helper).
|
||||||
|
OldHelperRemoved(keep_awake::AdminOutcome),
|
||||||
|
/// What the closed lid did with the logo last (logo::watch_lid_status).
|
||||||
|
LogoLidStatus(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProbeDone {
|
struct ProbeDone {
|
||||||
@@ -318,6 +334,10 @@ struct App {
|
|||||||
fullscreen_retry: Option<(Instant, u8)>,
|
fullscreen_retry: Option<(Instant, u8)>,
|
||||||
/// The child that watches the connected server and blinks the logo.
|
/// The child that watches the connected server and blinks the logo.
|
||||||
logo_watch: logo::Watcher,
|
logo_watch: logo::Watcher,
|
||||||
|
/// The worker that keeps this Mac awake while its agents work.
|
||||||
|
keep_awake: keep_awake::KeepAwake,
|
||||||
|
/// The administrator prompt for removing the old lid helper is open.
|
||||||
|
old_helper_busy: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -325,6 +345,10 @@ fn main() {
|
|||||||
if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) {
|
if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) {
|
||||||
logo::blink_main();
|
logo::blink_main();
|
||||||
}
|
}
|
||||||
|
// So is the lid keeper (lid_awake.rs).
|
||||||
|
if std::env::args().nth(1).as_deref() == Some(lid_awake::LID_ARG) {
|
||||||
|
lid_awake::child_main();
|
||||||
|
}
|
||||||
|
|
||||||
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
|
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
|
||||||
let proxy = event_loop.create_proxy();
|
let proxy = event_loop.create_proxy();
|
||||||
@@ -355,6 +379,10 @@ fn main() {
|
|||||||
let config_path = config_path.filter(|_| can_save);
|
let config_path = config_path.filter(|_| can_save);
|
||||||
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
|
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
|
||||||
logo::recover_at_launch();
|
logo::recover_at_launch();
|
||||||
|
// A lid keeper that was killed left lid sleep off; and 1.50.1's helper, where
|
||||||
|
// it is still installed, gets nothing to act on.
|
||||||
|
lid_awake::heal();
|
||||||
|
keep_awake::forget_legacy_request();
|
||||||
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
|
let menu = build_menu(&config).unwrap_or_else(|e| fatal(&format!("Could not build the menu: {e}")));
|
||||||
let phase = Rc::new(RefCell::new(Phase::Connect));
|
let phase = Rc::new(RefCell::new(Phase::Connect));
|
||||||
// Started once: the probe workers and the event loop share this queue,
|
// Started once: the probe workers and the event loop share this queue,
|
||||||
@@ -370,6 +398,7 @@ fn main() {
|
|||||||
let mut windows = HashMap::new();
|
let mut windows = HashMap::new();
|
||||||
windows.insert(main_id, AppWindow::new(webview, window, Kind::Main));
|
windows.insert(main_id, AppWindow::new(webview, window, Kind::Main));
|
||||||
|
|
||||||
|
let keep_awake_proxy = proxy.clone();
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
proxy,
|
proxy,
|
||||||
config,
|
config,
|
||||||
@@ -397,8 +426,19 @@ fn main() {
|
|||||||
add_agent_on_load: false,
|
add_agent_on_load: false,
|
||||||
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
|
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
|
||||||
logo_watch: logo::Watcher::default(),
|
logo_watch: logo::Watcher::default(),
|
||||||
|
keep_awake: keep_awake::KeepAwake::new(move |generation, text| {
|
||||||
|
let _ = keep_awake_proxy.send_event(UserEvent::KeepAwakeStatus { generation, text });
|
||||||
|
}),
|
||||||
|
old_helper_busy: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if app.menu.logo_lid.is_some() {
|
||||||
|
let lid_proxy = app.proxy.clone();
|
||||||
|
logo::watch_lid_status(move |line| {
|
||||||
|
let _ = lid_proxy.send_event(UserEvent::LogoLidStatus(line));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
|
event_loop.run(move |event, target, control_flow| app.handle(event, target, control_flow));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,6 +743,18 @@ fn build_webview(
|
|||||||
Ok(webview)
|
Ok(webview)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hides the whole app, as Cmd+H does. A click on the Dock icon (or Cmd+Tab)
|
||||||
|
/// shows it again with every window as it was, a full-screen one included,
|
||||||
|
/// which hiding the main window alone would not do.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn hide_app(target: &EventLoopWindowTarget<UserEvent>) {
|
||||||
|
use tao::platform::macos::EventLoopWindowTargetExtMacOS;
|
||||||
|
target.hide_application();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn hide_app(_target: &EventLoopWindowTarget<UserEvent>) {}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn handle(
|
fn handle(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -719,17 +771,20 @@ impl App {
|
|||||||
self.menu.help_menu.set_as_help_menu_for_nsapp();
|
self.menu.help_menu.set_as_help_menu_for_nsapp();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, control_flow),
|
Event::WindowEvent { window_id, event, .. } => self.on_window_event(window_id, event, target, control_flow),
|
||||||
Event::UserEvent(event) => self.on_user_event(event, target),
|
Event::UserEvent(event) => self.on_user_event(event, target),
|
||||||
// A click on the Dock icon while no window is visible, which is
|
// A click on the Dock icon while no window is visible, which is
|
||||||
// the main window minimized: tao only reports it, so bring the
|
// the main window minimized: tao only reports it, so bring the
|
||||||
// window back here.
|
// window back here. An app hidden by closing its window
|
||||||
|
// (`hide_app`) needs nothing here: AppKit unhides it, with every
|
||||||
|
// window as it was, before it reports the click.
|
||||||
Event::Reopen {
|
Event::Reopen {
|
||||||
has_visible_windows: false,
|
has_visible_windows: false,
|
||||||
..
|
..
|
||||||
} => self.show_main_window(),
|
} => self.show_main_window(),
|
||||||
Event::MainEventsCleared => self.on_events_cleared(control_flow),
|
Event::MainEventsCleared => self.on_events_cleared(control_flow),
|
||||||
Event::LoopDestroyed => {
|
Event::LoopDestroyed => {
|
||||||
|
self.keep_awake.shutdown();
|
||||||
self.record_main_frame();
|
self.record_main_frame();
|
||||||
self.save_config();
|
self.save_config();
|
||||||
}
|
}
|
||||||
@@ -790,17 +845,34 @@ impl App {
|
|||||||
self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1));
|
self.fullscreen_retry = Some((now + FULLSCREEN_RETRY_EVERY, attempts + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_window_event(&mut self, id: WindowId, event: WindowEvent, control_flow: &mut ControlFlow) {
|
fn on_window_event(
|
||||||
|
&mut self,
|
||||||
|
id: WindowId,
|
||||||
|
event: WindowEvent,
|
||||||
|
target: &EventLoopWindowTarget<UserEvent>,
|
||||||
|
control_flow: &mut ControlFlow,
|
||||||
|
) {
|
||||||
match event {
|
match event {
|
||||||
|
WindowEvent::CloseRequested if id != self.main_id => self.remove_window(id),
|
||||||
WindowEvent::CloseRequested => {
|
WindowEvent::CloseRequested => {
|
||||||
if id == self.main_id {
|
self.record_main_frame();
|
||||||
|
self.save_config();
|
||||||
|
let server = self.phase.borrow().loaded().cloned();
|
||||||
|
let mode = self.config.keep_awake.mode;
|
||||||
|
if keep_awake::available()
|
||||||
|
&& keep_awake::close_hides_app(mode, server.as_ref(), keep_awake::local_addresses)
|
||||||
|
{
|
||||||
|
// Keep This Mac Awake ends with the app, so closing the
|
||||||
|
// window while it watches a Codeman on this Mac hides
|
||||||
|
// Codeman instead, as Cmd+H does (full screen and pop-outs
|
||||||
|
// included): the Mac stays awake, a click on the Dock icon
|
||||||
|
// brings it back, and Cmd+Q still quits.
|
||||||
|
hide_app(target);
|
||||||
|
} else {
|
||||||
// Closing the main window quits; the server and its
|
// Closing the main window quits; the server and its
|
||||||
// sessions keep running, so nothing is lost.
|
// sessions keep running, so nothing is lost.
|
||||||
self.record_main_frame();
|
self.keep_awake.shutdown();
|
||||||
self.save_config();
|
|
||||||
*control_flow = ControlFlow::Exit;
|
*control_flow = ControlFlow::Exit;
|
||||||
} else {
|
|
||||||
self.remove_window(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(),
|
WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(),
|
||||||
@@ -821,6 +893,7 @@ impl App {
|
|||||||
self.to_connect_page(msg);
|
self.to_connect_page(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UserEvent::LogoLidStatus(line) => self.show_lid_status(line),
|
||||||
UserEvent::ServerStartDone {
|
UserEvent::ServerStartDone {
|
||||||
generation,
|
generation,
|
||||||
url,
|
url,
|
||||||
@@ -875,6 +948,7 @@ impl App {
|
|||||||
*self.phase.borrow_mut() = next;
|
*self.phase.borrow_mut() = next;
|
||||||
}
|
}
|
||||||
self.sync_logo_watch();
|
self.sync_logo_watch();
|
||||||
|
self.sync_keep_awake();
|
||||||
}
|
}
|
||||||
if let Some(w) = self.windows.get_mut(&window) {
|
if let Some(w) = self.windows.get_mut(&window) {
|
||||||
w.committed = Url::parse(&url).ok();
|
w.committed = Url::parse(&url).ok();
|
||||||
@@ -937,6 +1011,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
UserEvent::AuthRejected => self.recheck_login(),
|
UserEvent::AuthRejected => self.recheck_login(),
|
||||||
UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window),
|
UserEvent::ContentProcessGone { window } => self.on_content_process_gone(window),
|
||||||
|
UserEvent::KeepAwakeStatus { generation, text } => {
|
||||||
|
if self.keep_awake.is_current(generation) {
|
||||||
|
if let Some(items) = &self.menu.keep_awake {
|
||||||
|
items.status.set_text(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UserEvent::OldHelperRemoved(outcome) => self.on_old_helper_removed(outcome),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,35 +1112,170 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The logo is watched while any style is chosen and a server is open.
|
/// The logo is watched while the switch is on, a state has a style and a
|
||||||
|
/// server is open.
|
||||||
fn sync_logo_watch(&mut self) {
|
fn sync_logo_watch(&mut self) {
|
||||||
let styles = self.config.logo_blink;
|
let styles = self.config.logo_blink;
|
||||||
let phase = self.phase.borrow();
|
let phase = self.phase.borrow();
|
||||||
let server = phase.loaded().filter(|_| styles.any() && logo::available());
|
let server = phase.loaded().filter(|_| styles.active() && logo::available());
|
||||||
self.logo_watch
|
self.logo_watch
|
||||||
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
|
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
|
||||||
|
drop(phase);
|
||||||
|
self.show_lid_status(logo::lid_status_line());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The line under the logo's styles: what the closed lid did last, or that
|
||||||
|
/// nothing watches it now.
|
||||||
|
fn show_lid_status(&self, line: String) {
|
||||||
|
if let Some(item) = &self.menu.logo_lid {
|
||||||
|
if self.logo_watch.watching() {
|
||||||
|
item.set_text(line);
|
||||||
|
} else {
|
||||||
|
item.set_text(logo::LID_NOT_WATCHED);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
|
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
|
||||||
// A click flips the item it landed on, the chosen one included, so all
|
let was_active = self.config.logo_blink.active();
|
||||||
// of that state's are set.
|
|
||||||
for (item_signal, item_style, item) in &self.menu.logo_styles {
|
|
||||||
if *item_signal == signal {
|
|
||||||
item.set_checked(*item_style == style);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let was_off = !self.config.logo_blink.any();
|
|
||||||
self.config.logo_blink.set(signal, style);
|
self.config.logo_blink.set(signal, style);
|
||||||
|
// Picking a style is asking for the logo to show it.
|
||||||
|
if style != BlinkStyle::Off {
|
||||||
|
self.config.logo_blink.enabled = true;
|
||||||
|
}
|
||||||
|
self.logo_blink_changed(was_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one switch. Turned on with every state off, it brings the recommended
|
||||||
|
/// styles, so that on always shows something.
|
||||||
|
fn toggle_logo_blink(&mut self) {
|
||||||
|
let was_active = self.config.logo_blink.active();
|
||||||
|
let on = !self.config.logo_blink.enabled;
|
||||||
|
self.config.logo_blink.enabled = on;
|
||||||
|
if on && !self.config.logo_blink.any_style() {
|
||||||
|
self.config.logo_blink = logo::BlinkStyles::RECOMMENDED;
|
||||||
|
}
|
||||||
|
self.logo_blink_changed(was_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logo_blink_changed(&mut self, was_active: bool) {
|
||||||
|
// A click flips the item it landed on, so every item is set from the
|
||||||
|
// settings, the clicked one included.
|
||||||
|
let styles = self.config.logo_blink;
|
||||||
|
if let Some(switch) = &self.menu.logo_switch {
|
||||||
|
switch.set_checked(styles.enabled);
|
||||||
|
}
|
||||||
|
for (signal, style, item) in &self.menu.logo_styles {
|
||||||
|
item.set_checked(styles.get(*signal) == *style);
|
||||||
|
}
|
||||||
self.config_dirty = true;
|
self.config_dirty = true;
|
||||||
self.sync_logo_watch();
|
self.sync_logo_watch();
|
||||||
// Only from off: with a style running, a dark logo may be our own.
|
// Only from off: with a blink running, a dark logo may be our own.
|
||||||
if was_off && style != BlinkStyle::Off && logo::switched_off() {
|
if was_active || !styles.active() {
|
||||||
if let Some(main) = self.windows.get(&self.main_id) {
|
return;
|
||||||
let _ = main
|
}
|
||||||
.webview
|
let warning = if logo::switched_off() {
|
||||||
.evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning"));
|
LOGO_SWITCHED_OFF
|
||||||
|
} else if logo::driven_elsewhere() {
|
||||||
|
LOGO_DRIVEN_ELSEWHERE
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(main) = self.windows.get(&self.main_id) {
|
||||||
|
let _ = main.webview.evaluate_script(&assets::toast_script(warning, "warning"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep-awake watches the open server while a mode is chosen. Whether that
|
||||||
|
/// server is this Mac is the worker's to find out.
|
||||||
|
fn sync_keep_awake(&mut self) {
|
||||||
|
let mut settings = self.config.keep_awake;
|
||||||
|
// The lid item only exists on a Mac with a lid; a setting copied from one
|
||||||
|
// does nothing elsewhere.
|
||||||
|
settings.lid &= keep_awake::has_lid();
|
||||||
|
let server = self.phase.borrow().loaded().cloned();
|
||||||
|
let watch = server
|
||||||
|
.filter(|_| settings.mode != keep_awake::Mode::Off)
|
||||||
|
.map(|server| keep_awake::Watch {
|
||||||
|
server,
|
||||||
|
authorization: self.auth.clone(),
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
let idle = match (settings.mode, &watch) {
|
||||||
|
(keep_awake::Mode::Off, _) => Some(keep_awake::OFF_TEXT),
|
||||||
|
(_, None) => Some(keep_awake::NO_SERVER_TEXT),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
self.keep_awake.sync(watch);
|
||||||
|
if let (Some(text), Some(items)) = (idle, &self.menu.keep_awake) {
|
||||||
|
items.status.set_text(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_keep_awake_mode(&mut self, mode: keep_awake::Mode) {
|
||||||
|
if let Some(items) = &self.menu.keep_awake {
|
||||||
|
// A click flips the item it landed on, the chosen one included.
|
||||||
|
for (item_mode, item) in &items.modes {
|
||||||
|
item.set_checked(*item_mode == mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.config.keep_awake.mode = mode;
|
||||||
|
self.config_dirty = true;
|
||||||
|
self.sync_keep_awake();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_keep_awake_ac_only(&mut self) {
|
||||||
|
let Some(item) = self.menu.keep_awake.as_ref().and_then(|items| items.ac_only.as_ref()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.config.keep_awake.ac_only = item.is_checked();
|
||||||
|
self.config_dirty = true;
|
||||||
|
self.sync_keep_awake();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_keep_awake_lid(&mut self) {
|
||||||
|
let Some(item) = self.menu.keep_awake.as_ref().and_then(|items| items.lid.as_ref()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.config.keep_awake.lid = item.is_checked();
|
||||||
|
self.config_dirty = true;
|
||||||
|
self.sync_keep_awake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Codeman 1.50.1 kept the lid awake through a root helper. Nothing uses it any
|
||||||
|
/// more, and taking it away is the one thing left that asks for the
|
||||||
|
/// administrator password. The prompt runs off the event loop.
|
||||||
|
fn remove_old_helper(&mut self) {
|
||||||
|
if self.old_helper_busy {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.old_helper_busy = true;
|
||||||
|
let proxy = self.proxy.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = proxy.send_event(UserEvent::OldHelperRemoved(keep_awake::remove_old_helper()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_old_helper_removed(&mut self, outcome: keep_awake::AdminOutcome) {
|
||||||
|
use keep_awake::AdminOutcome;
|
||||||
|
self.old_helper_busy = false;
|
||||||
|
let failure = match (outcome, keep_awake::old_helper_installed()) {
|
||||||
|
(AdminOutcome::Cancelled, _) => None,
|
||||||
|
(AdminOutcome::Failed(error), _) => Some(error),
|
||||||
|
(AdminOutcome::Done, false) => {
|
||||||
|
if let Some(items) = &mut self.menu.keep_awake {
|
||||||
|
items.old_helper_removed();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
(AdminOutcome::Done, true) => Some("The removal ran, but the helper's files are still there.".to_owned()),
|
||||||
|
};
|
||||||
|
if let Some(detail) = failure {
|
||||||
|
let title = "The old lid helper could not be removed.";
|
||||||
|
eprintln!("codeman-desktop: {title} {detail}");
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
macos::show_message(title, &detail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_menu(&mut self, id: &str) {
|
fn on_menu(&mut self, id: &str) {
|
||||||
@@ -1066,10 +1283,18 @@ impl App {
|
|||||||
self.choose_logo_blink(signal, style);
|
self.choose_logo_blink(signal, style);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if let Some(mode) = keep_awake::Mode::from_menu_id(id) {
|
||||||
|
self.choose_keep_awake_mode(mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
match id {
|
match id {
|
||||||
|
keep_awake::AC_ONLY_ID => self.toggle_keep_awake_ac_only(),
|
||||||
|
keep_awake::LID_ID => self.toggle_keep_awake_lid(),
|
||||||
|
keep_awake::REMOVE_OLD_HELPER_ID => self.remove_old_helper(),
|
||||||
menu_id::SWITCH => self.show_connect_page(None, true),
|
menu_id::SWITCH => self.show_connect_page(None, true),
|
||||||
menu_id::ADD_AGENT => self.add_agent(),
|
menu_id::ADD_AGENT => self.add_agent(),
|
||||||
menu_id::RELOAD => self.reload_focused(),
|
menu_id::RELOAD => self.reload_focused(),
|
||||||
|
menu_id::LOGO_BLINK => self.toggle_logo_blink(),
|
||||||
menu_id::START_FULLSCREEN => {
|
menu_id::START_FULLSCREEN => {
|
||||||
let value = self.menu.start_fullscreen.is_checked();
|
let value = self.menu.start_fullscreen.is_checked();
|
||||||
self.config.start_fullscreen = value;
|
self.config.start_fullscreen = value;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use muda::accelerator::{Accelerator, Code, Modifiers};
|
|||||||
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::keep_awake::{self, Mode};
|
||||||
use crate::logo::{self, BlinkStyle, Signal};
|
use crate::logo::{self, BlinkStyle, Signal};
|
||||||
use crate::{APP_NAME, VERSION};
|
use crate::{APP_NAME, VERSION};
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ pub mod menu_id {
|
|||||||
pub const ADD_AGENT: &str = "add-agent";
|
pub const ADD_AGENT: &str = "add-agent";
|
||||||
pub const RELOAD: &str = "reload";
|
pub const RELOAD: &str = "reload";
|
||||||
pub const START_FULLSCREEN: &str = "start-fullscreen";
|
pub const START_FULLSCREEN: &str = "start-fullscreen";
|
||||||
|
pub const LOGO_BLINK: &str = "logo-blink";
|
||||||
pub const DEVTOOLS: &str = "devtools";
|
pub const DEVTOOLS: &str = "devtools";
|
||||||
pub const HELP_DOCS: &str = "help-docs";
|
pub const HELP_DOCS: &str = "help-docs";
|
||||||
pub const HELP_GITHUB: &str = "help-github";
|
pub const HELP_GITHUB: &str = "help-github";
|
||||||
@@ -26,9 +28,96 @@ pub struct AppMenu {
|
|||||||
pub window_menu: Submenu,
|
pub window_menu: Submenu,
|
||||||
pub help_menu: Submenu,
|
pub help_menu: Submenu,
|
||||||
pub start_fullscreen: CheckMenuItem,
|
pub start_fullscreen: CheckMenuItem,
|
||||||
/// One item per state and style. Empty on a Mac without a lit logo, which has no
|
/// The one switch for the logo. None on a Mac without a lit logo, which has no
|
||||||
/// such menu.
|
/// such menu.
|
||||||
|
pub logo_switch: Option<CheckMenuItem>,
|
||||||
|
/// One item per state and style, empty without the switch.
|
||||||
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
|
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
|
||||||
|
/// What the closed lid did with the logo last, a line that cannot be
|
||||||
|
/// clicked. None without the switch.
|
||||||
|
pub logo_lid: Option<MenuItem>,
|
||||||
|
/// None where the app cannot keep the Mac awake (a build for another OS).
|
||||||
|
pub keep_awake: Option<KeepAwakeMenu>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App menu > Keep This Mac Awake: Off / While Agents Work / Always, then on a
|
||||||
|
/// MacBook Only When Plugged In and With the Lid Closed, then one status line.
|
||||||
|
pub struct KeepAwakeMenu {
|
||||||
|
pub modes: Vec<(Mode, CheckMenuItem)>,
|
||||||
|
/// Only on a Mac with a lid: a desktop Mac always runs on mains and cannot close.
|
||||||
|
pub ac_only: Option<CheckMenuItem>,
|
||||||
|
/// No password behind it (lid_awake.rs).
|
||||||
|
pub lid: Option<CheckMenuItem>,
|
||||||
|
/// Only while the root helper Codeman 1.50.1 installed for the lid is still on
|
||||||
|
/// this Mac. Removing it asks for the administrator password, hence the ellipsis.
|
||||||
|
remove_old_helper: Option<MenuItem>,
|
||||||
|
submenu: Submenu,
|
||||||
|
/// What it does right now (keep_awake::status_line). Not clickable.
|
||||||
|
pub status: MenuItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeepAwakeMenu {
|
||||||
|
/// The old helper is gone, and with it the item that removes it.
|
||||||
|
pub fn old_helper_removed(&mut self) {
|
||||||
|
if let Some(item) = self.remove_old_helper.take() {
|
||||||
|
let _ = self.submenu.remove(&item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_keep_awake(config: &Config) -> muda::Result<(Submenu, KeepAwakeMenu)> {
|
||||||
|
let settings = config.keep_awake;
|
||||||
|
let submenu = Submenu::new("Keep This Mac Awake", true);
|
||||||
|
let mut modes = Vec::new();
|
||||||
|
for mode in Mode::ALL {
|
||||||
|
let item = CheckMenuItem::with_id(mode.menu_id(), mode.label(), true, settings.mode == mode, None);
|
||||||
|
submenu.append(&item)?;
|
||||||
|
modes.push((mode, item));
|
||||||
|
}
|
||||||
|
let (mut ac_only, mut lid) = (None, None);
|
||||||
|
if keep_awake::has_lid() {
|
||||||
|
let ac = CheckMenuItem::with_id(
|
||||||
|
keep_awake::AC_ONLY_ID,
|
||||||
|
"Only When Plugged In",
|
||||||
|
true,
|
||||||
|
settings.ac_only,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let lid_item = CheckMenuItem::with_id(keep_awake::LID_ID, "With the Lid Closed", true, settings.lid, None);
|
||||||
|
submenu.append_items(&[&PredefinedMenuItem::separator(), &ac, &lid_item])?;
|
||||||
|
(ac_only, lid) = (Some(ac), Some(lid_item));
|
||||||
|
}
|
||||||
|
let mut remove_old_helper = None;
|
||||||
|
if keep_awake::old_helper_installed() {
|
||||||
|
let item = MenuItem::with_id(
|
||||||
|
keep_awake::REMOVE_OLD_HELPER_ID,
|
||||||
|
"Remove the Old Lid Helper\u{2026}",
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
if lid.is_none() {
|
||||||
|
submenu.append(&PredefinedMenuItem::separator())?;
|
||||||
|
}
|
||||||
|
submenu.append(&item)?;
|
||||||
|
remove_old_helper = Some(item);
|
||||||
|
}
|
||||||
|
let initial = match settings.mode {
|
||||||
|
Mode::Off => keep_awake::OFF_TEXT,
|
||||||
|
_ => keep_awake::NO_SERVER_TEXT,
|
||||||
|
};
|
||||||
|
let status = MenuItem::new(initial, false, None);
|
||||||
|
submenu.append_items(&[&PredefinedMenuItem::separator(), &status])?;
|
||||||
|
Ok((
|
||||||
|
submenu.clone(),
|
||||||
|
KeepAwakeMenu {
|
||||||
|
modes,
|
||||||
|
ac_only,
|
||||||
|
lid,
|
||||||
|
remove_old_helper,
|
||||||
|
submenu,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||||
@@ -67,6 +156,15 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
|||||||
&PredefinedMenuItem::quit(None),
|
&PredefinedMenuItem::quit(None),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
// Right under "Add a Coding Agent to This Mac…".
|
||||||
|
let keep_awake = match keep_awake::available() {
|
||||||
|
true => {
|
||||||
|
let (submenu, items) = build_keep_awake(config)?;
|
||||||
|
app_menu.insert(&submenu, 4)?;
|
||||||
|
Some(items)
|
||||||
|
}
|
||||||
|
false => None,
|
||||||
|
};
|
||||||
// Without these items Cmd+C / Cmd+V do nothing inside a WKWebView.
|
// Without these items Cmd+C / Cmd+V do nothing inside a WKWebView.
|
||||||
let edit = Submenu::with_items(
|
let edit = Submenu::with_items(
|
||||||
"Edit",
|
"Edit",
|
||||||
@@ -110,10 +208,20 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
// Only a Mac with a lit Apple logo has this menu at all.
|
// Only a Mac with a lit Apple logo has these items at all: the switch, and the
|
||||||
|
// style for each state behind it.
|
||||||
let mut logo_styles = Vec::new();
|
let mut logo_styles = Vec::new();
|
||||||
|
let mut logo_switch = None;
|
||||||
|
let mut logo_lid = None;
|
||||||
if logo::available() {
|
if logo::available() {
|
||||||
let submenu = Submenu::new("Blink Apple Logo", true);
|
let switch = CheckMenuItem::with_id(
|
||||||
|
menu_id::LOGO_BLINK,
|
||||||
|
"Blink Apple Logo",
|
||||||
|
true,
|
||||||
|
config.logo_blink.enabled,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let submenu = Submenu::new("Apple Logo Blink Styles", true);
|
||||||
for signal in Signal::ALL {
|
for signal in Signal::ALL {
|
||||||
let group = Submenu::new(signal.label(), true);
|
let group = Submenu::new(signal.label(), true);
|
||||||
for style in BlinkStyle::ALL {
|
for style in BlinkStyle::ALL {
|
||||||
@@ -128,7 +236,12 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
|||||||
submenu.append(&group)?;
|
submenu.append(&group)?;
|
||||||
}
|
}
|
||||||
// Right under "Always Open in Full Screen", before the separator.
|
// Right under "Always Open in Full Screen", before the separator.
|
||||||
view.insert(&submenu, 4)?;
|
let lid = MenuItem::new(logo::lid_status_line(), false, None);
|
||||||
|
view.insert(&switch, 4)?;
|
||||||
|
view.insert(&submenu, 5)?;
|
||||||
|
view.insert(&lid, 6)?;
|
||||||
|
logo_switch = Some(switch);
|
||||||
|
logo_lid = Some(lid);
|
||||||
}
|
}
|
||||||
let window_menu = Submenu::with_items(
|
let window_menu = Submenu::with_items(
|
||||||
"Window",
|
"Window",
|
||||||
@@ -156,6 +269,9 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
|||||||
window_menu,
|
window_menu,
|
||||||
help_menu,
|
help_menu,
|
||||||
start_fullscreen,
|
start_fullscreen,
|
||||||
|
logo_switch,
|
||||||
logo_styles,
|
logo_styles,
|
||||||
|
keep_awake,
|
||||||
|
logo_lid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "codeman",
|
"name": "codeman",
|
||||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
"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.50.0",
|
"version": "1.51.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Ark0N",
|
"name": "Ark0N",
|
||||||
"url": "https://github.com/Ark0N"
|
"url": "https://github.com/Ark0N"
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
@@ -135,8 +135,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
|||||||
mkdir -p "$(dirname "$PRE")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||||
grep -qs '^CODEMAN_PREAMBLE=1.50.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
grep -qs '^CODEMAN_PREAMBLE=1.51.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||||
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.51.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||||
# from that body (test/agent-skill.test.ts pins it).
|
# from that body (test/agent-skill.test.ts pins it).
|
||||||
@@ -197,6 +197,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
|
|||||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||||
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
||||||
}
|
}
|
||||||
|
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
|
||||||
|
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
|
||||||
|
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
|
||||||
|
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
|
||||||
|
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
|
||||||
|
# RENDERED pane with its whitespace removed, and never handed back while it shows.
|
||||||
|
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
|
||||||
|
| jq -r '.data.terminalBuffer // empty' \
|
||||||
|
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
|
||||||
|
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
|
||||||
|
}
|
||||||
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
||||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||||
# "No, exit" by default:
|
# "No, exit" by default:
|
||||||
@@ -259,9 +270,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
||||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||||
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||||
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
|
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
|
||||||
|
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
|
||||||
|
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
|
||||||
|
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
|
||||||
|
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
|
||||||
|
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
|
||||||
|
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
|
||||||
|
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
|
||||||
|
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
|
||||||
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
||||||
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
|
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
|
||||||
|
# that can run the model is on this Mac), blocked (installed, but the server
|
||||||
# refuses a session now: the model changed on disk, another program holds the port, the
|
# refuses a session now: the model changed on disk, another program holds the port, the
|
||||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||||
# local models, 404), unauthorized (401, §6), unreachable, error.
|
# local models, 404), unauthorized (401, §6), unreachable, error.
|
||||||
@@ -277,12 +297,23 @@ local_model() {
|
|||||||
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
||||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||||
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
||||||
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
|
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
|
||||||
|
agents:(if ($i.harnesses | type) == "array"
|
||||||
|
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
|
||||||
|
else null end),
|
||||||
|
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
|
||||||
|
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
|
||||||
|
else null end)} as $base
|
||||||
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
||||||
elif $i.state == "installed" then
|
elif $i.state == "installed" then
|
||||||
|
# With the agents listed and none of them startable there is nothing to spawn,
|
||||||
|
# running or not: say that, not "blocked".
|
||||||
|
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
|
||||||
|
then {spawnable:false, reason:"no-agent",
|
||||||
|
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
|
||||||
# `start` alone over-promises: it is offered too while another program holds the
|
# `start` alone over-promises: it is offered too while another program holds the
|
||||||
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
||||||
if ($i.cli and $i.endpointId and $i.model.id)
|
elif ($i.cli and $i.endpointId and $i.model.id)
|
||||||
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
||||||
then {spawnable:true} + $base
|
then {spawnable:true} + $base
|
||||||
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
||||||
@@ -345,31 +376,55 @@ _local_ready_model() {
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
|
||||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
|
||||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
|
||||||
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
|
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
|
||||||
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
|
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
|
||||||
# never confirms on anyone's behalf.
|
# that wants a person to confirm something (a context below the agent's safe floor, a model
|
||||||
|
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
|
||||||
|
# agent. All local workers share ONE model that answers one request at a time.
|
||||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
# committing to an approach, on a recurring error and before declaring the task done.
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
|
||||||
|
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
|
||||||
if [ "$mode" = local ]; then
|
if [ "$mode" = local ]; then
|
||||||
lm=$(_local_ready_model) || return 1
|
lm=$(_local_ready_model) || return 1
|
||||||
mode=$(jq -r .cli <<<"$lm")
|
if [ -n "$want" ]; then
|
||||||
|
# Only an agent the server lists as startable. A Codeman that lists none (older than
|
||||||
|
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
|
||||||
|
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
|
||||||
|
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
|
||||||
|
case "$r" in
|
||||||
|
ready) ;;
|
||||||
|
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
|
||||||
|
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
|
||||||
|
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
mode=$want
|
||||||
|
else
|
||||||
|
mode=$(jq -r .cli <<<"$lm")
|
||||||
|
fi
|
||||||
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
||||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
# Readiness is a token the agent's composer draws once it takes input. The server names
|
||||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
|
||||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
# For a server that sends none, the two this skill measured itself:
|
||||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
|
||||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
# at the mark was submitted every time).
|
||||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
|
||||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
|
||||||
[ "$mode" = opencode ] && mark='┃'
|
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
|
||||||
|
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
|
||||||
|
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
|
||||||
|
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
|
||||||
|
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
|
||||||
|
# claude takes the claude path below.
|
||||||
|
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
|
||||||
|
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
|
||||||
mark=${LOCAL_READY_MARK:-$mark}
|
mark=${LOCAL_READY_MARK:-$mark}
|
||||||
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
||||||
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
||||||
@@ -384,11 +439,16 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
||||||
# so a claude worker on the local model never gets one.
|
# so a claude worker on the local model never gets one.
|
||||||
|
# pi on the local model: a custom model session gives pi a home of its own, so it would
|
||||||
|
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
|
||||||
|
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
|
||||||
|
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
||||||
|
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
|
||||||
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
||||||
# The answers that want a PERSON to decide come back as success with no session id.
|
# The answers that want a PERSON to decide come back as success with no session id.
|
||||||
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
||||||
@@ -405,11 +465,18 @@ spawn_worker() {
|
|||||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
fi
|
fi
|
||||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
|
||||||
# why sendwait refuses it (400) and sendmark drives it.
|
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
|
||||||
|
# worker reports the end of a turn on the local model too, but sendmark is still the way
|
||||||
|
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
|
||||||
|
# new one `last-response` comes back empty for it.
|
||||||
r=$(_mark_up "$sid" "$mark" 45000)
|
r=$(_mark_up "$sid" "$mark" 45000)
|
||||||
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
||||||
delete_session "$sid" >/dev/null; return 1; }
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
|
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
|
||||||
|
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
|
||||||
|
delete_session "$sid" >/dev/null; return 1
|
||||||
|
fi
|
||||||
printf '%s\n' "$sid"; return 0
|
printf '%s\n' "$sid"; return 0
|
||||||
fi
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
@@ -425,6 +492,10 @@ spawn_worker() {
|
|||||||
r=$(_dsh_up "$sid" 45000)
|
r=$(_dsh_up "$sid" 45000)
|
||||||
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
|
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
|
||||||
delete_session "$sid" >/dev/null; return 1; }
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
|
if _dsh_guide "$sid"; then
|
||||||
|
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
|
||||||
|
delete_session "$sid" >/dev/null; return 1
|
||||||
|
fi
|
||||||
printf '%s\n' "$sid"; return 0
|
printf '%s\n' "$sid"; return 0
|
||||||
fi
|
fi
|
||||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
|
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
|
||||||
@@ -572,7 +643,7 @@ sendwait() {
|
|||||||
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
||||||
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
||||||
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
||||||
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
||||||
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
||||||
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
||||||
# it did it right, not even that the turn is over: a reasoning model can restate the
|
# it did it right, not even that the turn is over: a reasoning model can restate the
|
||||||
@@ -596,7 +667,12 @@ sendmark() {
|
|||||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||||
esac
|
esac
|
||||||
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
||||||
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
|
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
|
||||||
|
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
|
||||||
|
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
|
||||||
|
# calls started in one second for two workers in ONE folder shared a token, and one
|
||||||
|
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
|
||||||
|
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
|
||||||
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
||||||
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
||||||
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
||||||
@@ -683,10 +759,10 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.50.0
|
CODEMAN_PREAMBLE=1.51.0
|
||||||
PREAMBLE
|
PREAMBLE
|
||||||
)
|
)
|
||||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Every later Bash call that touches the API starts with the same two loader lines from
|
Every later Bash call that touches the API starts with the same two loader lines from
|
||||||
@@ -737,7 +813,7 @@ and no per-call body to hand-build.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
@@ -826,7 +902,8 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co
|
|||||||
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
|
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
|
||||||
instead of guessed-from-silence ones — and it writes a structured transcript, which is
|
instead of guessed-from-silence ones — and it writes a structured transcript, which is
|
||||||
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
|
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
|
||||||
`grok`, `omp` and `copilot` have neither, and `codex` has a transcript and a screen-read `blocked` but
|
`grok`, `omp`, `copilot` and `jcode` have neither (one exception: a local `omp` session reports
|
||||||
|
`blocked` for its tool approval dialogs, and still no `stop`), and `codex` has a transcript and a screen-read `blocked` but
|
||||||
no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
|
no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
|
||||||
|
|
||||||
Three things to know before you spawn one:
|
Three things to know before you spawn one:
|
||||||
@@ -850,9 +927,25 @@ Three things to know before you spawn one:
|
|||||||
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
|
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
|
||||||
on its welcome screen), you can hand work to that model from here. You stay the planner and
|
on its welcome screen), you can hand work to that model from here. You stay the planner and
|
||||||
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
|
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
|
||||||
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
|
on the installed model, run by its default coding agent (OpenCode on the Mac it was tested
|
||||||
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
|
on), and from then on it is the same verbs. It mixes into one fleet like any other mode:
|
||||||
other mode: `spawn_workers plan review:deepseek summ:local`.
|
`spawn_workers plan review:deepseek summ:local`.
|
||||||
|
|
||||||
|
**Which coding agent runs the worker is yours to choose.** `local_model` lists them as
|
||||||
|
`agents`, each with a `status`; `spawn_worker <name> local:<cli>` starts one that is `ready`:
|
||||||
|
|
||||||
|
| Worker kind | Agent | Why you would pick it | Drive it with |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `local` | the default one (`cli` of `local_model`) | you have no reason to choose | `sendmark` (`sendwait` when it is claude) |
|
||||||
|
| `local:opencode` | OpenCode | the tested pair; a full tool set | `sendmark` |
|
||||||
|
| `local:pi` | Pi | the smallest fixed prompt (about 2,100 tokens against 8,000 and more), so the first answer comes soonest and the most context is left for the work | `sendmark` |
|
||||||
|
| `local:deepseek` | DeepSeek Harness | the user already works with it | `sendmark` (it reports the end of a turn, but on dsh 0.2.2 its answer cannot be read back through `sendwait` yet) |
|
||||||
|
| `local:claude` | Claude Code | hooks and `sendwait`, at the price of a first answer that takes minutes | `sendwait` |
|
||||||
|
|
||||||
|
An agent whose status is `installable`, `installing` or `setup-needed` is not startable, and
|
||||||
|
the spawn says so with the ones that are. Installing an agent is a person's click in the
|
||||||
|
**Run a local model** window, like installing a model: tell the user, do not work around it.
|
||||||
|
A Codeman older than 1.51 lists no agents, and only plain `local` works there.
|
||||||
|
|
||||||
Reach for it when:
|
Reach for it when:
|
||||||
|
|
||||||
@@ -866,9 +959,9 @@ Reach for it when:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
|
local_model || exit 1 # one JSON line: model, agents, endpoint; or the reason there is none
|
||||||
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
|
SID=$(spawn_worker summ local) || exit 1 # or local:pi, local:deepseek; starts a stopped runtime first; empty = failed
|
||||||
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
|
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
|
||||||
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
|
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
|
||||||
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
|
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
|
||||||
@@ -891,8 +984,9 @@ The limits, before you promise the user anything:
|
|||||||
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
|
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
|
||||||
window. This skill never installs one: that is a download of many gigabytes the person
|
window. This skill never installs one: that is a download of many gigabytes the person
|
||||||
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
|
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
|
||||||
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
|
`install-unfinished`, `no-agent`, `blocked`, `admin-only`, `unsupported`), so tell the
|
||||||
instead of guessing.
|
user that instead of guessing. It also lists the models on this Mac (`models`); only the
|
||||||
|
`active` one runs, and switching to another is a person's decision in the window.
|
||||||
- **One model, one request at a time.** The runtime loads one model and answers one request
|
- **One model, one request at a time.** The runtime loads one model and answers one request
|
||||||
at a time, so two local workers take turns rather than work side by side. Fan out to the
|
at a time, so two local workers take turns rather than work side by side. Fan out to the
|
||||||
cloud; give the Mac a queue.
|
cloud; give the Mac a queue.
|
||||||
@@ -900,9 +994,20 @@ The limits, before you promise the user anything:
|
|||||||
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
|
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
|
||||||
loads the model first. A local worker run by claude spends minutes on its first answer
|
loads the model first. A local worker run by claude spends minutes on its first answer
|
||||||
(its fixed prompt is about 36,000 tokens).
|
(its fixed prompt is about 36,000 tokens).
|
||||||
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
|
- **Markers, not hooks.** OpenCode and Pi report no end of turn, so `sendwait` answers 400
|
||||||
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
|
for those workers; `sendmark` is the way through. A claude worker has hooks, so
|
||||||
has hooks and `sendwait` works as for any claude worker.
|
`sendwait` works for it. A DeepSeek Harness worker reports its turns too, but drive it
|
||||||
|
with `sendmark` on the local model: dsh 0.2.2 renamed its transcript file, and Codeman
|
||||||
|
reads an empty answer from it for now.
|
||||||
|
- **DeepSeek Harness has a setup guide a person finishes once.** Until then every dsh
|
||||||
|
session opens on it, and the spawn refuses the worker (and deletes it) instead of typing
|
||||||
|
a task into a sign-in screen. Tell the user to start one DeepSeek session from the Run
|
||||||
|
menu and finish the guide.
|
||||||
|
- **Pi starts without trusting the folder.** A `local:pi` worker is started with
|
||||||
|
`--no-approve`, so it loads no project extensions or skills from the case folder: on the
|
||||||
|
local model Pi would otherwise ask to trust the folder in every session, and a task
|
||||||
|
prompt must never answer that question. Pi also fetches its two search tools at each such
|
||||||
|
start, which needs the network for a moment.
|
||||||
- **Spawning can change what the person set.** A stopped runtime is started, which also
|
- **Spawning can change what the person set.** A stopped runtime is started, which also
|
||||||
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
|
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
|
||||||
open, the window refuses Stop and Remove and names your workers, so delete them as soon
|
open, the window refuses Stop and Remove and names your workers, so delete them as soon
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.51.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||||
# from that body (test/agent-skill.test.ts pins it).
|
# from that body (test/agent-skill.test.ts pins it).
|
||||||
@@ -59,6 +59,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
|
|||||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||||
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
||||||
}
|
}
|
||||||
|
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
|
||||||
|
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
|
||||||
|
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
|
||||||
|
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
|
||||||
|
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
|
||||||
|
# RENDERED pane with its whitespace removed, and never handed back while it shows.
|
||||||
|
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
|
||||||
|
| jq -r '.data.terminalBuffer // empty' \
|
||||||
|
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
|
||||||
|
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
|
||||||
|
}
|
||||||
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
||||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||||
# "No, exit" by default:
|
# "No, exit" by default:
|
||||||
@@ -121,9 +132,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
||||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||||
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||||
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
|
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
|
||||||
|
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
|
||||||
|
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
|
||||||
|
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
|
||||||
|
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
|
||||||
|
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
|
||||||
|
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
|
||||||
|
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
|
||||||
|
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
|
||||||
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
||||||
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
|
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
|
||||||
|
# that can run the model is on this Mac), blocked (installed, but the server
|
||||||
# refuses a session now: the model changed on disk, another program holds the port, the
|
# refuses a session now: the model changed on disk, another program holds the port, the
|
||||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||||
# local models, 404), unauthorized (401, §6), unreachable, error.
|
# local models, 404), unauthorized (401, §6), unreachable, error.
|
||||||
@@ -139,12 +159,23 @@ local_model() {
|
|||||||
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
||||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||||
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
||||||
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
|
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
|
||||||
|
agents:(if ($i.harnesses | type) == "array"
|
||||||
|
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
|
||||||
|
else null end),
|
||||||
|
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
|
||||||
|
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
|
||||||
|
else null end)} as $base
|
||||||
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
||||||
elif $i.state == "installed" then
|
elif $i.state == "installed" then
|
||||||
|
# With the agents listed and none of them startable there is nothing to spawn,
|
||||||
|
# running or not: say that, not "blocked".
|
||||||
|
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
|
||||||
|
then {spawnable:false, reason:"no-agent",
|
||||||
|
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
|
||||||
# `start` alone over-promises: it is offered too while another program holds the
|
# `start` alone over-promises: it is offered too while another program holds the
|
||||||
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
||||||
if ($i.cli and $i.endpointId and $i.model.id)
|
elif ($i.cli and $i.endpointId and $i.model.id)
|
||||||
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
||||||
then {spawnable:true} + $base
|
then {spawnable:true} + $base
|
||||||
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
||||||
@@ -207,31 +238,55 @@ _local_ready_model() {
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
|
||||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
|
||||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
|
||||||
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
|
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
|
||||||
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
|
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
|
||||||
# never confirms on anyone's behalf.
|
# that wants a person to confirm something (a context below the agent's safe floor, a model
|
||||||
|
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
|
||||||
|
# agent. All local workers share ONE model that answers one request at a time.
|
||||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
# committing to an approach, on a recurring error and before declaring the task done.
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
|
||||||
|
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
|
||||||
if [ "$mode" = local ]; then
|
if [ "$mode" = local ]; then
|
||||||
lm=$(_local_ready_model) || return 1
|
lm=$(_local_ready_model) || return 1
|
||||||
mode=$(jq -r .cli <<<"$lm")
|
if [ -n "$want" ]; then
|
||||||
|
# Only an agent the server lists as startable. A Codeman that lists none (older than
|
||||||
|
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
|
||||||
|
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
|
||||||
|
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
|
||||||
|
case "$r" in
|
||||||
|
ready) ;;
|
||||||
|
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
|
||||||
|
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
|
||||||
|
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
mode=$want
|
||||||
|
else
|
||||||
|
mode=$(jq -r .cli <<<"$lm")
|
||||||
|
fi
|
||||||
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
||||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
# Readiness is a token the agent's composer draws once it takes input. The server names
|
||||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
|
||||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
# For a server that sends none, the two this skill measured itself:
|
||||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
|
||||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
# at the mark was submitted every time).
|
||||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
|
||||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
|
||||||
[ "$mode" = opencode ] && mark='┃'
|
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
|
||||||
|
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
|
||||||
|
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
|
||||||
|
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
|
||||||
|
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
|
||||||
|
# claude takes the claude path below.
|
||||||
|
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
|
||||||
|
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
|
||||||
mark=${LOCAL_READY_MARK:-$mark}
|
mark=${LOCAL_READY_MARK:-$mark}
|
||||||
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
||||||
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
||||||
@@ -246,11 +301,16 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
||||||
# so a claude worker on the local model never gets one.
|
# so a claude worker on the local model never gets one.
|
||||||
|
# pi on the local model: a custom model session gives pi a home of its own, so it would
|
||||||
|
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
|
||||||
|
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
|
||||||
|
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
||||||
|
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
|
||||||
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
||||||
# The answers that want a PERSON to decide come back as success with no session id.
|
# The answers that want a PERSON to decide come back as success with no session id.
|
||||||
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
||||||
@@ -267,11 +327,18 @@ spawn_worker() {
|
|||||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
fi
|
fi
|
||||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
|
||||||
# why sendwait refuses it (400) and sendmark drives it.
|
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
|
||||||
|
# worker reports the end of a turn on the local model too, but sendmark is still the way
|
||||||
|
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
|
||||||
|
# new one `last-response` comes back empty for it.
|
||||||
r=$(_mark_up "$sid" "$mark" 45000)
|
r=$(_mark_up "$sid" "$mark" 45000)
|
||||||
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
||||||
delete_session "$sid" >/dev/null; return 1; }
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
|
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
|
||||||
|
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
|
||||||
|
delete_session "$sid" >/dev/null; return 1
|
||||||
|
fi
|
||||||
printf '%s\n' "$sid"; return 0
|
printf '%s\n' "$sid"; return 0
|
||||||
fi
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
@@ -287,6 +354,10 @@ spawn_worker() {
|
|||||||
r=$(_dsh_up "$sid" 45000)
|
r=$(_dsh_up "$sid" 45000)
|
||||||
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
|
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
|
||||||
delete_session "$sid" >/dev/null; return 1; }
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
|
if _dsh_guide "$sid"; then
|
||||||
|
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
|
||||||
|
delete_session "$sid" >/dev/null; return 1
|
||||||
|
fi
|
||||||
printf '%s\n' "$sid"; return 0
|
printf '%s\n' "$sid"; return 0
|
||||||
fi
|
fi
|
||||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
|
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
|
||||||
@@ -434,7 +505,7 @@ sendwait() {
|
|||||||
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
||||||
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
||||||
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
||||||
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
||||||
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
||||||
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
||||||
# it did it right, not even that the turn is over: a reasoning model can restate the
|
# it did it right, not even that the turn is over: a reasoning model can restate the
|
||||||
@@ -458,7 +529,12 @@ sendmark() {
|
|||||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||||
esac
|
esac
|
||||||
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
||||||
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
|
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
|
||||||
|
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
|
||||||
|
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
|
||||||
|
# calls started in one second for two workers in ONE folder shared a token, and one
|
||||||
|
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
|
||||||
|
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
|
||||||
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
||||||
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
||||||
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
||||||
@@ -545,4 +621,4 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.50.0
|
CODEMAN_PREAMBLE=1.51.0
|
||||||
@@ -216,7 +216,10 @@ you typed matches the moment it is typed.
|
|||||||
generic marker (`BUILD OK`) matched against stale text, either from `from=buffer`
|
generic marker (`BUILD OK`) matched against stale text, either from `from=buffer`
|
||||||
scanning an earlier run or from tmux replaying old screen content as fresh output on an
|
scanning an earlier run or from tmux replaying old screen content as fresh output on an
|
||||||
attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes
|
attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes
|
||||||
safe.
|
safe. ⚠️ In a fan-out put something of the WORKER into it too (its id, the loop index): zsh,
|
||||||
|
the shell Claude Code runs on a Mac, hands every subshell the same `$RANDOM` until the
|
||||||
|
parent shell reads it, so `( … $RANDOM … ) &` twice draws one number. `sendmark` adds six
|
||||||
|
characters of the worker's id for that reason.
|
||||||
|
|
||||||
### 6. `jq` parse error instead of an `errorCode`
|
### 6. `jq` parse error instead of an `errorCode`
|
||||||
|
|
||||||
@@ -242,7 +245,7 @@ minutes, never retry the credential.
|
|||||||
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
||||||
returns is too early (verified live: empty on the first call, full prose seconds later).
|
returns is too early (verified live: empty on the first call, full prose seconds later).
|
||||||
It is also `""` before the worker's first completed turn, and permanently `""` for
|
It is also `""` before the worker's first completed turn, and permanently `""` for
|
||||||
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `copilot`, which write no transcript at
|
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot` and `jcode`, which write no transcript at
|
||||||
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
|
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
|
||||||
for the same reason claude does (the harness finalizes the assistant message just after
|
for the same reason claude does (the harness finalizes the assistant message just after
|
||||||
it reports `idle`), so poll it the same way.
|
it reports `idle`), so poll it the same way.
|
||||||
@@ -346,7 +349,7 @@ ESC=$(printf '\033')
|
|||||||
|
|
||||||
`POST /api/v1/quick-start` body (all optional):
|
`POST /api/v1/quick-start` body (all optional):
|
||||||
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
||||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot`; response is
|
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot|jcode`; response is
|
||||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||||
|
|
||||||
@@ -361,9 +364,10 @@ a soft default the worker can change with `/advisor`. Remote and docker cases re
|
|||||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||||
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
|
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
|
||||||
`GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
|
`GET /api/v1/pi/status`, `GET /api/v1/omp/status`, `GET /api/v1/copilot/status` and `GET /api/v1/jcode/status` each return `.data.{available, path}` (no session needed).
|
||||||
Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
|
Pi's, grok's, OMP's, Copilot's and jcode's also carry `.data.version`, because `pi` is a short generic name,
|
||||||
`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
|
`grok` is a name with npm squatters, `omp` is a similarly short name, `copilot` is a generic one, and an
|
||||||
|
unrelated npm package is also called `jcode`, so an unrelated
|
||||||
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
||||||
not version-shaped, so `available:false` there can mean "a different program of the same
|
not version-shaped, so `available:false` there can mean "a different program of the same
|
||||||
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
||||||
@@ -492,8 +496,8 @@ Quirks that will bite you:
|
|||||||
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
||||||
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
||||||
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
|
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
|
||||||
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`. ⚠️ **`shell` is NOT one of those**
|
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`. ⚠️ **`shell` is NOT one of those**
|
||||||
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
|
(`isExternalCliMode` covers only those external CLIs), so the parser does
|
||||||
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
|
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
|
||||||
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
|
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
|
||||||
a shell worker running `cat build.log` really does populate this. In practice it stays
|
a shell worker running `cat build.log` really does populate this. In practice it stays
|
||||||
@@ -657,7 +661,7 @@ Three bounded long-polls. Shared semantics:
|
|||||||
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
|
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
|
||||||
| `working` | session started producing output | every mode |
|
| `working` | session started producing output | every mode |
|
||||||
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude`; also `deepseek`, whose harness reports its own end of turn while its status bridge is on |
|
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude`; also `deepseek`, whose harness reports its own end of turn while its status bridge is on |
|
||||||
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `deepseek` (its harness reports its dialogs, same condition), and `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer both in the terminal, never through the inbox) |
|
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `deepseek` (its harness reports its dialogs, same condition), `codex`, whose approval, question and trust dialogs Codeman reads off its screen, and a local `omp` session, whose tool approval dialogs a hook module in its workspace reports (answer all three in the terminal, never through the inbox) |
|
||||||
| `exit` | PTY exited or session deleted | every mode |
|
| `exit` | PTY exited or session deleted | every mode |
|
||||||
|
|
||||||
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
|
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
|
|||||||
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
||||||
| liveness / death check | HTTP `wait?until=exit` |
|
| liveness / death check | HTTP `wait?until=exit` |
|
||||||
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
||||||
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`) | HTTP only (no other CLI has messaging) |
|
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`) | HTTP only (no other CLI has messaging) |
|
||||||
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
||||||
|
|
||||||
## Availability: probe, never assume
|
## Availability: probe, never assume
|
||||||
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
|
|||||||
|
|
||||||
### Mixed fleets: the pairing matrix
|
### Mixed fleets: the pairing matrix
|
||||||
|
|
||||||
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`) cannot be peers
|
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, `jcode`) cannot be peers
|
||||||
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
||||||
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
||||||
subject to the namespace rule: **messaging works between two sessions that share one
|
subject to the namespace rule: **messaging works between two sessions that share one
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||||
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot, which have no transcript, use
|
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot/jcode, which have no transcript, use
|
||||||
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
||||||
|
|
||||||
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
||||||
|
|||||||
@@ -383,12 +383,17 @@ recovered by submitting it with `{"input":"\r"}`.
|
|||||||
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
|
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
|
||||||
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
||||||
real end-of-turn signal rather than a guess. On
|
real end-of-turn signal rather than a guess. On
|
||||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`, requesting them explicitly is a
|
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`/`jcode`, requesting them explicitly is a
|
||||||
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
|
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
|
||||||
question or trust dialog is up; a wait for it answers at once if one already is), and
|
question or trust dialog is up; a wait for it answers at once if one already is; and
|
||||||
|
`blocked` on a local `omp` session, next paragraph), and
|
||||||
lifecycle transitions there are coarse (a short shell command may emit **no**
|
lifecycle transitions there are coarse (a short shell command may emit **no**
|
||||||
`idle` transition at all, verified live), so synchronize those with markers.
|
`idle` transition at all, verified live), so synchronize those with markers.
|
||||||
|
|
||||||
|
`blocked` also works on a local `omp` session: Codeman installs a hook module into its
|
||||||
|
workspace that reports omp's approval dialogs (the inbox item is alert-only, so answer in
|
||||||
|
the pane). `stop` stays a 400 there, so wait on `idle` or a marker for end-of-turn.
|
||||||
|
|
||||||
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
|
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
|
||||||
false` at create time disarms the bridge, and an explicit `until=stop` is then a 400
|
false` at create time disarms the bridge, and an explicit `until=stop` is then a 400
|
||||||
naming that setting. And a `stop` that is *accepted* is not proof it will ever fire —
|
naming that setting. And a `stop` that is *accepted* is not proof it will ever fire —
|
||||||
@@ -437,7 +442,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
|
|||||||
single read taken the instant send-and-wait returns comes back `""` even though the
|
single read taken the instant send-and-wait returns comes back `""` even though the
|
||||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`; the first four
|
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`, `jcode`; the first four
|
||||||
verified live, pi from the same source path), which is
|
verified live, pi from the same source path), which is
|
||||||
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
|
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
|
||||||
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
|
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
|
||||||
@@ -526,7 +531,7 @@ turn), and both better than diffing terminal samples:
|
|||||||
```
|
```
|
||||||
|
|
||||||
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
||||||
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`** (those parsers are skipped wholesale) and
|
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`** (those parsers are skipped wholesale) and
|
||||||
in practice empty for `shell`. Source-verified, not measured live.
|
in practice empty for `shell`. Source-verified, not measured live.
|
||||||
|
|
||||||
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
|
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
|
||||||
@@ -831,9 +836,19 @@ can be spawned now (`running: true`) or once `spawn_worker` has started the runt
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||||
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
|
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B",
|
||||||
|
"agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
|
||||||
|
{"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
|
||||||
|
"models":[{"id":"qwen3.6:35b-a3b","label":"Qwen3.6 35B-A3B","active":true,"status":"ready","sizeGb":"23.1"}]}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`cli` is the default coding agent. `agents` is every agent the model can be run with
|
||||||
|
(`install.harnesses` of the route): `ready` can be started, `installable` and `installing`
|
||||||
|
are an install a person makes or made in the window, `setup-needed` is an agent with a setup
|
||||||
|
of its own (DeepSeek Harness without a terminal profile). `models` is what is on the Mac
|
||||||
|
(`install.library`); only the `active` one runs. A Codeman older than 1.51 sends neither
|
||||||
|
list, and both keys are then absent.
|
||||||
|
|
||||||
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
|
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
|
||||||
|
|
||||||
| `reason` | What it means | What to tell the user |
|
| `reason` | What it means | What to tell the user |
|
||||||
@@ -841,6 +856,7 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
|
|||||||
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
|
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
|
||||||
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
|
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
|
||||||
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
|
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
|
||||||
|
| `no-agent` | installed, and `agents` holds none that is `ready` | install a coding agent in that window (a chip that says "Not installed") |
|
||||||
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
|
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
|
||||||
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
|
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
|
||||||
| `unsupported` | the server has no local model routes (404) | it predates the feature |
|
| `unsupported` | the server has no local model routes (404) | it predates the feature |
|
||||||
@@ -849,29 +865,44 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
|
|||||||
It never downloads, installs or starts anything. Installing is many gigabytes the person
|
It never downloads, installs or starts anything. Installing is many gigabytes the person
|
||||||
confirms in the window, and no route this skill calls can do it.
|
confirms in the window, and no route this skill calls can do it.
|
||||||
|
|
||||||
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), in order:
|
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), and
|
||||||
|
**`spawn_worker <name> local:<cli>`** to choose the coding agent, in order:
|
||||||
|
|
||||||
1. `local_model`; anything but exit 0 fails the spawn with that reason.
|
1. `local_model`; anything but exit 0 fails the spawn with that reason. With `local:<cli>`
|
||||||
|
the agent must be a row of `agents` whose status is `ready`; any other status, or an
|
||||||
|
agent the model cannot be run with, fails the spawn with the ones that are startable.
|
||||||
|
On a Codeman that lists no agents only its one `cli` can be named. No agent is ever
|
||||||
|
installed from here.
|
||||||
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
|
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
|
||||||
window's Start button sends, then `local_model` again until it reports `running`
|
window's Start button sends, then `local_model` again until it reports `running`
|
||||||
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
|
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
|
||||||
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
|
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
|
||||||
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
|
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
|
||||||
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
|
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
|
||||||
session", with the installed agent as the mode. No advisor, which is a cloud model.
|
session", with the chosen agent as the mode. No advisor, which is a cloud model. A Pi
|
||||||
|
worker also carries `"piConfig":{"approveProjectTrust":false}` (`--no-approve`): on a
|
||||||
|
custom model Pi keeps no home, so it would ask to trust the folder in every session with
|
||||||
|
Trust preselected, and the task prompt's Enter would answer it.
|
||||||
The server checks again that its own runtime runs and that the model on disk is the one
|
The server checks again that its own runtime runs and that the model on disk is the one
|
||||||
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
|
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
|
||||||
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
|
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
|
||||||
person (a context below the agent's safe floor, a model swap that would evict another
|
person (a context below the agent's safe floor, a model swap that would evict another
|
||||||
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
|
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
|
||||||
and no `confirmed*` flag is ever sent.
|
and no `confirmed*` flag is ever sent.
|
||||||
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
|
5. Readiness: a token the agent's composer draws once it takes input, through
|
||||||
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
|
`wait-output`, 45 s, and the session deleted on a miss. The token is the agent's
|
||||||
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
|
`readyMark` from `local_model` when the server sends one, else the one measured for this
|
||||||
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
|
skill, and `LOCAL_READY_MARK` overrides both. All of it was measured on Linux through a
|
||||||
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
|
custom model launch, none of it on a Mac yet:
|
||||||
dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its
|
|
||||||
composer draws, or the spawn is refused before a session exists.
|
| Agent | Token | What was measured |
|
||||||
|
|-------|-------|-------------------|
|
||||||
|
| OpenCode | `┃` | 1.3.0 and 1.19.5: nothing draws it before the composer, no first-run screen, and a prompt pasted at the first `┃` was submitted (2 of 2 on 1.19.5) |
|
||||||
|
| DeepSeek Harness | `❯`, then a look at the pane | 0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1: splash, composer and status line arrive in one frame; a prompt sent at the mark was submitted (2 of 2). The harness reports idle about 150 ms BEFORE that frame, so `stop` is no readiness signal. 0.2.2-rc.1 with dsh-TUI 0.19.0: a setup guide ("Step 1 of 4") opens in every session until a person has finished it once, and its cursor is `❯` too (the mark matched on it, 2 of 2), so after the mark the rendered pane is read and a worker that shows the guide is deleted, never handed back; with the guide finished, a prompt sent at the mark was submitted |
|
||||||
|
| Pi | `extended-keys` | 1.5.2: Pi paints its composer rules in its first frame and ignores Enter until startup has finished, so the rule `─` is NOT a mark (a prompt sent at it stayed in the composer, 2 of 2). The last line of its startup is a warning that tmux's `extended-keys` option is off, which it is on Codeman's socket; a prompt sent once that word showed was submitted (4 of 4). A side effect, not a contract: a Pi that stops printing it fails the spawn until `LOCAL_READY_MARK` names what it draws instead |
|
||||||
|
|
||||||
|
A claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
|
||||||
|
dialog). An agent with no token at all is refused before a session exists.
|
||||||
|
|
||||||
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
|
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
|
||||||
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
|
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ console.log('\n[build] content-hash cache busting');
|
|||||||
'settings-ui.js',
|
'settings-ui.js',
|
||||||
'panels-ui.js',
|
'panels-ui.js',
|
||||||
'session-ui.js',
|
'session-ui.js',
|
||||||
'ralph-wizard.js',
|
|
||||||
'api-client.js',
|
'api-client.js',
|
||||||
'subagent-windows.js',
|
'subagent-windows.js',
|
||||||
'image-input.js',
|
'image-input.js',
|
||||||
|
|||||||
@@ -1,482 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* capture-readme-gifs.mjs
|
|
||||||
*
|
|
||||||
* Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the
|
|
||||||
* mock-injection pipeline from capture-readme-screenshots.mjs (static file
|
|
||||||
* server + page.route mocks), drives a scripted timeline in the page, records
|
|
||||||
* it with Playwright video, and converts to GIF via ffmpeg palette encoding.
|
|
||||||
*
|
|
||||||
* Scenes:
|
|
||||||
* 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent
|
|
||||||
* windows open one by one and stream tool-call activity live (driven
|
|
||||||
* through the real _onSubagentDiscovered/_onSubagentToolCall handlers).
|
|
||||||
* 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag)
|
|
||||||
* vs bursty ~350 ms server echo, rendered with the vendored xterm.
|
|
||||||
*
|
|
||||||
* Usage: node scripts/capture-readme-gifs.mjs
|
|
||||||
* SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs
|
|
||||||
* Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR)
|
|
||||||
* Requires: ffmpeg
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import { mkdtempSync, rmSync } from 'fs';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
import {
|
|
||||||
PORT,
|
|
||||||
SESSION_IDS,
|
|
||||||
STANDARD_SESSIONS,
|
|
||||||
buildInitPayload,
|
|
||||||
startStaticServer,
|
|
||||||
setupRoutes,
|
|
||||||
injectState,
|
|
||||||
outPath,
|
|
||||||
RST, GRN, YEL, MAG, CYN, GRY, BOLD,
|
|
||||||
} from './capture-readme-screenshots.mjs';
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
const GIF_COLORS = 192;
|
|
||||||
|
|
||||||
// ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ────────
|
|
||||||
|
|
||||||
function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) {
|
|
||||||
// One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame
|
|
||||||
// palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch
|
|
||||||
// on flat dark UI, and error-diffusion dither shimmers between frames.
|
|
||||||
const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`;
|
|
||||||
execSync(
|
|
||||||
`ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` +
|
|
||||||
`-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` +
|
|
||||||
`[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`,
|
|
||||||
{ stdio: 'inherit' }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Scene 1: subagent demo ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const SUBAGENT_VIEWPORT = { width: 1440, height: 810 };
|
|
||||||
|
|
||||||
// Terminal content visible before the agents spawn
|
|
||||||
const TERMINAL_PRESPAWN = [
|
|
||||||
'',
|
|
||||||
`${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`,
|
|
||||||
'',
|
|
||||||
`${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`,
|
|
||||||
` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`,
|
|
||||||
'',
|
|
||||||
`${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`,
|
|
||||||
` ${GRY}░${RST} ./src/index.ts`,
|
|
||||||
` ${GRY}░${RST} ./src/session.ts`,
|
|
||||||
` ${GRY}░${RST} ./src/web/server.ts`,
|
|
||||||
` ${GRY}░${RST} ${GRY}... (17 more)${RST}`,
|
|
||||||
'',
|
|
||||||
`${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`,
|
|
||||||
'',
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
function makeAgent(agentId, description, startedOffsetMs) {
|
|
||||||
return {
|
|
||||||
agentId,
|
|
||||||
sessionId: 'claude-sess-w1-0001',
|
|
||||||
projectHash: 'abc123',
|
|
||||||
filePath: `/tmp/${agentId}.jsonl`,
|
|
||||||
startedAt: new Date(Date.now() - startedOffsetMs).toISOString(),
|
|
||||||
lastActivityAt: Date.now(),
|
|
||||||
status: 'active',
|
|
||||||
toolCallCount: 0,
|
|
||||||
entryCount: 0,
|
|
||||||
fileSize: 4000,
|
|
||||||
description,
|
|
||||||
model: 'claude-haiku-4-5-20251001',
|
|
||||||
modelShort: 'haiku',
|
|
||||||
totalInputTokens: 0,
|
|
||||||
totalOutputTokens: 0,
|
|
||||||
parentSessionId: SESSION_IDS.w1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeline events: t (ms from scene start) + kind
|
|
||||||
// term — write raw data to the session terminal
|
|
||||||
// discover — register subagent + open + position its floating window
|
|
||||||
// tool — stream a tool call into an agent window
|
|
||||||
// msg — stream an assistant message into an agent window
|
|
||||||
// complete — flip an agent to completed
|
|
||||||
function buildSubagentTimeline() {
|
|
||||||
const T = (lines) => lines.join('\r\n') + '\r\n';
|
|
||||||
const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input });
|
|
||||||
const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text });
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
t: 600,
|
|
||||||
kind: 'term',
|
|
||||||
data: T([
|
|
||||||
`${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`,
|
|
||||||
` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`,
|
|
||||||
'',
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
t: 1000,
|
|
||||||
kind: 'discover',
|
|
||||||
agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000),
|
|
||||||
x: 440, y: 45,
|
|
||||||
},
|
|
||||||
tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }),
|
|
||||||
{
|
|
||||||
t: 2000,
|
|
||||||
kind: 'term',
|
|
||||||
data: T([
|
|
||||||
`${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`,
|
|
||||||
` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`,
|
|
||||||
'',
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }),
|
|
||||||
{
|
|
||||||
t: 2500,
|
|
||||||
kind: 'discover',
|
|
||||||
agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200),
|
|
||||||
x: 880, y: 45,
|
|
||||||
},
|
|
||||||
tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }),
|
|
||||||
{
|
|
||||||
t: 3300,
|
|
||||||
kind: 'term',
|
|
||||||
data: T([
|
|
||||||
`${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`,
|
|
||||||
` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`,
|
|
||||||
'',
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }),
|
|
||||||
{
|
|
||||||
t: 3800,
|
|
||||||
kind: 'discover',
|
|
||||||
agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400),
|
|
||||||
x: 660, y: 400,
|
|
||||||
},
|
|
||||||
tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }),
|
|
||||||
{
|
|
||||||
t: 4500,
|
|
||||||
kind: 'term',
|
|
||||||
data: T([
|
|
||||||
`${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`,
|
|
||||||
'',
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }),
|
|
||||||
tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }),
|
|
||||||
tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }),
|
|
||||||
tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }),
|
|
||||||
msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'),
|
|
||||||
tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }),
|
|
||||||
msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'),
|
|
||||||
tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }),
|
|
||||||
msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'),
|
|
||||||
{
|
|
||||||
t: 9300,
|
|
||||||
kind: 'term',
|
|
||||||
data: T([
|
|
||||||
`${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`,
|
|
||||||
`${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`,
|
|
||||||
`${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`,
|
|
||||||
'',
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }),
|
|
||||||
tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }),
|
|
||||||
tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }),
|
|
||||||
{
|
|
||||||
t: 11600,
|
|
||||||
kind: 'term',
|
|
||||||
data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame
|
|
||||||
|
|
||||||
async function recordSubagentScene(browser, videoDir) {
|
|
||||||
console.log('\n1/2 Recording subagent-demo...');
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: SUBAGENT_VIEWPORT,
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT },
|
|
||||||
});
|
|
||||||
const recStart = Date.now();
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(30000);
|
|
||||||
|
|
||||||
// Start with NO subagents — they appear during the recording
|
|
||||||
const initPayload = buildInitPayload(STANDARD_SESSIONS);
|
|
||||||
await setupRoutes(page, initPayload, TERMINAL_PRESPAWN);
|
|
||||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1);
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
try { window.app?.fitAddon?.fit(); } catch {}
|
|
||||||
window.app?.terminal?.scrollToBottom();
|
|
||||||
});
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const timeline = buildSubagentTimeline();
|
|
||||||
const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD;
|
|
||||||
const sceneStart = Date.now();
|
|
||||||
|
|
||||||
// Run the whole timeline inside the page so events interleave naturally
|
|
||||||
await page.evaluate((events) => {
|
|
||||||
const app = window.app;
|
|
||||||
for (const ev of events) {
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
if (ev.kind === 'term') {
|
|
||||||
app.terminal.write(ev.data);
|
|
||||||
app.terminal.scrollToBottom();
|
|
||||||
} else if (ev.kind === 'discover') {
|
|
||||||
app._onSubagentDiscovered(ev.agent);
|
|
||||||
app.openSubagentWindow(ev.agent.agentId);
|
|
||||||
// The spawn animation (400ms) lands on the auto-grid; glide to our tile after it
|
|
||||||
setTimeout(() => {
|
|
||||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
|
||||||
if (win?.element) {
|
|
||||||
win.element.style.transition = 'left 0.25s ease, top 0.25s ease';
|
|
||||||
win.element.style.left = `${ev.x}px`;
|
|
||||||
win.element.style.top = `${ev.y}px`;
|
|
||||||
}
|
|
||||||
}, 520);
|
|
||||||
setTimeout(() => {
|
|
||||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
|
||||||
if (win?.element) win.element.style.transition = '';
|
|
||||||
app.updateConnectionLines();
|
|
||||||
}, 850);
|
|
||||||
} else if (ev.kind === 'tool') {
|
|
||||||
app._onSubagentToolCall({
|
|
||||||
agentId: ev.agentId,
|
|
||||||
tool: ev.tool,
|
|
||||||
input: ev.input,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} else if (ev.kind === 'msg') {
|
|
||||||
app._onSubagentMessage({
|
|
||||||
agentId: ev.agentId,
|
|
||||||
role: 'assistant',
|
|
||||||
text: ev.text,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} else if (ev.kind === 'complete') {
|
|
||||||
app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('timeline event failed', ev, err);
|
|
||||||
}
|
|
||||||
}, ev.t);
|
|
||||||
}
|
|
||||||
}, timeline);
|
|
||||||
|
|
||||||
await sleep(totalMs + 500);
|
|
||||||
|
|
||||||
await page.close();
|
|
||||||
const videoPath = await page.video().path();
|
|
||||||
await context.close();
|
|
||||||
|
|
||||||
return {
|
|
||||||
videoPath,
|
|
||||||
ss: (sceneStart - recStart) / 1000 - 0.4,
|
|
||||||
duration: (totalMs + 400) / 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Scene 2: zerolag typing comparison ──────────────────────────────────────
|
|
||||||
|
|
||||||
const ZEROLAG_VIEWPORT = { width: 1280, height: 470 };
|
|
||||||
const TYPED_TEXT = 'echo "zero lag typing from anywhere"';
|
|
||||||
const TYPE_INTERVAL_MS = 110;
|
|
||||||
const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts
|
|
||||||
const ZEROLAG_TAIL_HOLD = 1800;
|
|
||||||
|
|
||||||
const ZEROLAG_HTML = `<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" href="http://localhost:${PORT}/vendor/xterm.css">
|
|
||||||
<script src="http://localhost:${PORT}/vendor/xterm.min.js"></script>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
width: 1280px; height: 470px; background: #0a0a0c;
|
|
||||||
display: flex; align-items: center; justify-content: center; gap: 48px;
|
|
||||||
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
.pane { width: 560px; }
|
|
||||||
.card {
|
|
||||||
background: #131316; border: 1px solid rgba(255,255,255,0.08);
|
|
||||||
border-radius: 10px; overflow: hidden;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
|
||||||
}
|
|
||||||
.card-head {
|
|
||||||
display: flex; align-items: baseline; gap: 10px;
|
|
||||||
padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
||||||
}
|
|
||||||
.dot { width: 9px; height: 9px; border-radius: 50%; align-self: center; }
|
|
||||||
.title { font-size: 15px; font-weight: 600; color: #e8e8ea; }
|
|
||||||
.sub { font-size: 12.5px; color: #8b8b92; }
|
|
||||||
.term { padding: 16px 8px 12px 16px; height: 165px; }
|
|
||||||
.good .dot { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.7); }
|
|
||||||
.bad .dot { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.7); }
|
|
||||||
.tag {
|
|
||||||
margin-top: 14px; text-align: center; font-size: 14.5px; color: #7e7e86;
|
|
||||||
}
|
|
||||||
.tag b { color: #22c55e; font-weight: 600; }
|
|
||||||
.bad-tag b { color: #ef4444; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="pane">
|
|
||||||
<div class="card good">
|
|
||||||
<div class="card-head">
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span class="title">With zerolag-input</span>
|
|
||||||
<span class="sub">instant local echo</span>
|
|
||||||
</div>
|
|
||||||
<div class="term" id="termLeft"></div>
|
|
||||||
</div>
|
|
||||||
<div class="tag">keystrokes echo in <b>0 ms</b></div>
|
|
||||||
</div>
|
|
||||||
<div class="pane">
|
|
||||||
<div class="card bad">
|
|
||||||
<div class="card-head">
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span class="title">Without</span>
|
|
||||||
<span class="sub">server round-trip echo</span>
|
|
||||||
</div>
|
|
||||||
<div class="term" id="termRight"></div>
|
|
||||||
</div>
|
|
||||||
<div class="tag bad-tag">keystrokes echo after <b>~350 ms</b></div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
async function recordZerolagScene(browser, videoDir) {
|
|
||||||
console.log('\n2/2 Recording zerolag-demo...');
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: ZEROLAG_VIEWPORT,
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT },
|
|
||||||
});
|
|
||||||
const recStart = Date.now();
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(30000);
|
|
||||||
|
|
||||||
await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' });
|
|
||||||
await page.waitForFunction(() => typeof Terminal !== 'undefined');
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const theme = {
|
|
||||||
background: '#131316',
|
|
||||||
foreground: '#e8e8ea',
|
|
||||||
cursor: '#22c55e',
|
|
||||||
cursorAccent: '#131316',
|
|
||||||
};
|
|
||||||
const mk = (id) => {
|
|
||||||
const term = new Terminal({
|
|
||||||
cols: 44,
|
|
||||||
rows: 5,
|
|
||||||
fontSize: 20,
|
|
||||||
fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace",
|
|
||||||
cursorBlink: true,
|
|
||||||
cursorStyle: 'block',
|
|
||||||
theme,
|
|
||||||
});
|
|
||||||
term.open(document.getElementById(id));
|
|
||||||
term.write('\x1b[32m❯\x1b[0m ');
|
|
||||||
return term;
|
|
||||||
};
|
|
||||||
window.termLeft = mk('termLeft');
|
|
||||||
window.termRight = mk('termRight');
|
|
||||||
});
|
|
||||||
await sleep(600);
|
|
||||||
|
|
||||||
const sceneStart = Date.now();
|
|
||||||
const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS;
|
|
||||||
const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD;
|
|
||||||
|
|
||||||
await page.evaluate(
|
|
||||||
({ text, interval, flushEvery }) => {
|
|
||||||
let i = 0;
|
|
||||||
const remoteQueue = [];
|
|
||||||
const typer = setInterval(() => {
|
|
||||||
if (i >= text.length) { clearInterval(typer); return; }
|
|
||||||
const ch = text[i++];
|
|
||||||
window.termLeft.write(ch); // local echo: instant
|
|
||||||
remoteQueue.push(ch); // server echo: waits for the round-trip
|
|
||||||
}, interval);
|
|
||||||
const flusher = setInterval(() => {
|
|
||||||
if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join(''));
|
|
||||||
if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher);
|
|
||||||
}, flushEvery);
|
|
||||||
},
|
|
||||||
{ text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
await sleep(totalMs + 400);
|
|
||||||
|
|
||||||
await page.close();
|
|
||||||
const videoPath = await page.video().path();
|
|
||||||
await context.close();
|
|
||||||
|
|
||||||
return {
|
|
||||||
videoPath,
|
|
||||||
ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors
|
|
||||||
duration: (totalMs + 600) / 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
console.log('Codeman README GIF Capture');
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
|
|
||||||
const server = await startStaticServer();
|
|
||||||
const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-'));
|
|
||||||
let browser;
|
|
||||||
|
|
||||||
try {
|
|
||||||
browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const sub = await recordSubagentScene(browser, videoDir);
|
|
||||||
const subGif = outPath('images', 'subagent-demo.gif');
|
|
||||||
webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 });
|
|
||||||
console.log(` Saved: ${subGif}`);
|
|
||||||
|
|
||||||
const zl = await recordZerolagScene(browser, videoDir);
|
|
||||||
const zlGif = outPath('images', 'zerolag-demo.gif');
|
|
||||||
webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 });
|
|
||||||
console.log(` Saved: ${zlGif}`);
|
|
||||||
|
|
||||||
console.log('\nDone.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('\nFatal error:', err.message);
|
|
||||||
console.error(err.stack);
|
|
||||||
process.exitCode = 1;
|
|
||||||
} finally {
|
|
||||||
if (browser) await browser.close().catch(() => {});
|
|
||||||
server.close();
|
|
||||||
rmSync(videoDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGINT', () => process.exit(1));
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* capture-readme-real.mjs
|
|
||||||
*
|
|
||||||
* Captures README desktop scenes (multi-session dashboard, monitor, subagent
|
|
||||||
* windows) from a REAL Codeman instance — intended to run against an ISOLATED
|
|
||||||
* dev/beta instance (CODEMAN_INSTANCE=beta on :5000) seeded from prod's settings,
|
|
||||||
* NOT prod itself (never touch prod's live sessions).
|
|
||||||
*
|
|
||||||
* Reuses the high-quality capture recipe proven in capture-real-overview.mjs:
|
|
||||||
* - DSF=2 + ?nowebgl → crisp retina at the TRUE font size (WebGL doubles
|
|
||||||
* glyphs under DSF=2; the DOM renderer respects devicePixelRatio).
|
|
||||||
* - per-device localStorage seeding so the capture matches a real device.
|
|
||||||
*
|
|
||||||
* SCENE=dashboard|monitor|subagent|all BASE=http://localhost:5000 \
|
|
||||||
* OUT=screenshots-readme-real/desktop node scripts/capture-readme-real.mjs
|
|
||||||
*/
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import { mkdirSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
|
||||||
const OUT = process.env.OUT || 'screenshots-readme-real/desktop';
|
|
||||||
const SKIN = process.env.SKIN || 'daylight-blue';
|
|
||||||
const SCENE = process.env.SCENE || 'all';
|
|
||||||
const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 13)));
|
|
||||||
const VIEWPORT = { width: Number(process.env.VW || 1280), height: Number(process.env.VH || 720) };
|
|
||||||
const DSF = Number(process.env.DSF || 2);
|
|
||||||
const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
const url = (extra = '') => {
|
|
||||||
const sep = BASE.includes('?') ? '&' : '?';
|
|
||||||
const params = [];
|
|
||||||
if (DSF > 1) params.push('nowebgl'); // DOM renderer → correct font size at DSF>1
|
|
||||||
if (extra) params.push(extra);
|
|
||||||
return params.length ? `${BASE}${sep}${params.join('&')}` : BASE;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function newCtx(browser) {
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: VIEWPORT,
|
|
||||||
deviceScaleFactor: DSF,
|
|
||||||
ignoreHTTPSErrors: BASE.startsWith('https'),
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(30000);
|
|
||||||
await page.addInitScript(
|
|
||||||
([skin, planUsage, font]) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('codeman:skin', skin);
|
|
||||||
localStorage.setItem('codeman-font-size', String(font));
|
|
||||||
const blob = { skin, showFileBrowser: false, showProjectInsights: false, showTokenCount: false };
|
|
||||||
// Don't auto-hide subagent windows that belong to a non-active tab — the
|
|
||||||
// subagent scene re-homes agents and needs both windows visible at once.
|
|
||||||
blob.subagentActiveTabOnly = false;
|
|
||||||
if (planUsage) blob.showPlanUsageLimits = true;
|
|
||||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[SKIN, PLAN_USAGE, FONT]
|
|
||||||
);
|
|
||||||
return { context, page };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrap(page) {
|
|
||||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
|
|
||||||
await sleep(1200);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listSessions(page) {
|
|
||||||
return page.evaluate(() =>
|
|
||||||
Array.from(window.app.sessions.values()).map((s) => ({ id: s.id, name: s.name, mode: s.mode }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shoot(page, name) {
|
|
||||||
const out = join(OUT, name);
|
|
||||||
await page.evaluate((f) => {
|
|
||||||
try {
|
|
||||||
if (window.app.setFontSize) window.app.setFontSize(f);
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
window.app.fitAddon && window.app.fitAddon.fit();
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
|
|
||||||
} catch {}
|
|
||||||
}, FONT);
|
|
||||||
await sleep(1500);
|
|
||||||
await page.screenshot({ path: out, fullPage: false });
|
|
||||||
console.log(' Saved: ' + out);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sceneDashboard(browser) {
|
|
||||||
console.log('Scene: dashboard');
|
|
||||||
const { context, page } = await newCtx(browser);
|
|
||||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
|
||||||
await bootstrap(page);
|
|
||||||
const sessions = await listSessions(page);
|
|
||||||
// Select a claude session so the active terminal shows rich content; all tabs render.
|
|
||||||
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
|
|
||||||
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
|
|
||||||
await sleep(4000);
|
|
||||||
await shoot(page, 'multi-session-dashboard.png');
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sceneMonitor(browser) {
|
|
||||||
console.log('Scene: monitor');
|
|
||||||
const { context, page } = await newCtx(browser);
|
|
||||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
|
||||||
await bootstrap(page);
|
|
||||||
const sessions = await listSessions(page);
|
|
||||||
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
|
|
||||||
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
|
|
||||||
await sleep(2500);
|
|
||||||
// toggleMonitorPanel() opens the panel, clears the hidden state, loads REAL
|
|
||||||
// mux sessions (/api/mux), starts stats, and renders the task panel.
|
|
||||||
await page.evaluate(async () => {
|
|
||||||
try {
|
|
||||||
await window.app.toggleMonitorPanel();
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
await sleep(3000);
|
|
||||||
await shoot(page, 'multi-session-monitor.png');
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sceneSubagent(browser) {
|
|
||||||
console.log('Scene: subagent');
|
|
||||||
const { context, page } = await newCtx(browser);
|
|
||||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
|
||||||
await bootstrap(page);
|
|
||||||
// Select the session whose subagents we want (subagentActiveTabOnly means
|
|
||||||
// app.subagents only fills for the active tab). Prefer SUBAGENT_SID env.
|
|
||||||
const sessions = await listSessions(page);
|
|
||||||
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
|
|
||||||
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
|
|
||||||
// Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents.
|
|
||||||
let agents = [];
|
|
||||||
for (let i = 0; i < 45; i++) {
|
|
||||||
agents = await page.evaluate(() =>
|
|
||||||
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
|
|
||||||
);
|
|
||||||
if (agents.length >= 1) break;
|
|
||||||
await sleep(1000);
|
|
||||||
}
|
|
||||||
console.log(' live in-browser subagents:', JSON.stringify(agents));
|
|
||||||
if (agents.length === 0) {
|
|
||||||
console.log(' NO live subagents — skipping (stage a longer subagent task and run this while it runs).');
|
|
||||||
await context.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The window body renders from app.subagentActivity, which fills ONLY from live
|
|
||||||
// SSE tool-call/progress events — a fresh client never gets past activity replayed.
|
|
||||||
// So sit connected and wait for live activity to accumulate, then open the two
|
|
||||||
// agents that actually have content (otherwise the windows read "No activity yet").
|
|
||||||
let active = [];
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
active = await page.evaluate(() =>
|
|
||||||
Array.from(window.app.subagentActivity?.entries?.() || [])
|
|
||||||
.filter(([, arr]) => Array.isArray(arr) && arr.length >= 1)
|
|
||||||
.map(([id, arr]) => ({ id, n: arr.length }))
|
|
||||||
.sort((a, b) => b.n - a.n)
|
|
||||||
);
|
|
||||||
if (active.length >= 2) break;
|
|
||||||
// xhigh-effort agents churn in bursts between long thinking pauses, so be
|
|
||||||
// patient (~150s); accept a single populated window after ~45s if that's all.
|
|
||||||
if (i >= 30 && active.length >= 1) break;
|
|
||||||
await sleep(1500);
|
|
||||||
}
|
|
||||||
console.log(' agents with live activity:', JSON.stringify(active));
|
|
||||||
const openIds = (active.length ? active : agents).map((a) => a.id);
|
|
||||||
// Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the
|
|
||||||
// Codeman id and never becomes the real Claude conversation UUID, so the window
|
|
||||||
// open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both
|
|
||||||
// fail. Re-home the chosen agents onto the active tab and align its claudeSessionId
|
|
||||||
// to the agents' (shared) sessionId so the windows open AND show their live activity.
|
|
||||||
await page.evaluate(
|
|
||||||
(ids) => {
|
|
||||||
const activeId = window.app.activeSessionId;
|
|
||||||
const tab = window.app.sessions.get(activeId);
|
|
||||||
ids.slice(0, 2).forEach((id) => {
|
|
||||||
const a = window.app.subagents.get(id);
|
|
||||||
if (!a) return;
|
|
||||||
a.parentSessionId = activeId;
|
|
||||||
if (tab && a.sessionId) tab.claudeSessionId = a.sessionId;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
openIds
|
|
||||||
);
|
|
||||||
await page.evaluate(
|
|
||||||
(ids) => {
|
|
||||||
ids.slice(0, 2).forEach((id) => {
|
|
||||||
try {
|
|
||||||
window.app.openSubagentWindow(id);
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
openIds
|
|
||||||
);
|
|
||||||
await sleep(2000);
|
|
||||||
await page.evaluate(() => {
|
|
||||||
// Viewport-relative tiling: center two subagent windows over the terminal so
|
|
||||||
// the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650
|
|
||||||
// recipe) instead of overflowing at narrower widths.
|
|
||||||
const wins = Array.from(window.app.subagentWindows.values());
|
|
||||||
const W = window.innerWidth;
|
|
||||||
const H = window.innerHeight;
|
|
||||||
const winW = Math.min(440, Math.floor((W - 60) / 2 - 10));
|
|
||||||
const winH = Math.min(360, Math.floor(H * 0.56));
|
|
||||||
const top = Math.floor(H * 0.16);
|
|
||||||
const gap = 16;
|
|
||||||
const totalW = winW * 2 + gap;
|
|
||||||
const startLeft = Math.max(16, Math.floor((W - totalW) / 2));
|
|
||||||
wins.slice(0, 2).forEach((win, i) => {
|
|
||||||
const el = win.element;
|
|
||||||
// Force visible: a freshly opened window may be hidden by the activeTabOnly
|
|
||||||
// rule before we override it (we also seed subagentActiveTabOnly:false).
|
|
||||||
win.hidden = false;
|
|
||||||
win.minimized = false;
|
|
||||||
el.style.display = 'flex';
|
|
||||||
el.style.left = startLeft + i * (winW + gap) + 'px';
|
|
||||||
el.style.top = top + 'px';
|
|
||||||
el.style.width = winW + 'px';
|
|
||||||
el.style.height = winH + 'px';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await sleep(1500);
|
|
||||||
await shoot(page, 'subagent-spawn.png');
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
mkdirSync(OUT, { recursive: true });
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
|
||||||
});
|
|
||||||
console.log(`BASE=${BASE} SKIN=${SKIN} DSF=${DSF} VIEWPORT=${VIEWPORT.width}x${VIEWPORT.height} SCENE=${SCENE}`);
|
|
||||||
if (SCENE === 'dashboard' || SCENE === 'all') await sceneDashboard(browser);
|
|
||||||
if (SCENE === 'monitor' || SCENE === 'all') await sceneMonitor(browser);
|
|
||||||
if (SCENE === 'subagent' || SCENE === 'all') await sceneSubagent(browser);
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error('FATAL', e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,731 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* capture-video-screenshots.mjs
|
|
||||||
*
|
|
||||||
* Captures real Codeman UI screenshots for the Remotion demo video.
|
|
||||||
* Uses Playwright + page.route() mock injection (same pattern as capture-readme-screenshots.mjs).
|
|
||||||
* No real Claude CLI or server needed — all API responses are mocked.
|
|
||||||
*
|
|
||||||
* Usage: node scripts/capture-video-screenshots.mjs
|
|
||||||
* Port: 3198 (static file server)
|
|
||||||
* Output: scripts/scripts/remotion/public/ (6 PNGs)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import { createServer } from 'http';
|
|
||||||
import { readFileSync, existsSync } from 'fs';
|
|
||||||
import { join, extname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
||||||
const PROJECT_ROOT = join(__dirname, '..');
|
|
||||||
const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public');
|
|
||||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public');
|
|
||||||
const PORT = 3198;
|
|
||||||
|
|
||||||
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
|
|
||||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
// ─── MIME Types ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const MIME_TYPES = {
|
|
||||||
'.html': 'text/html',
|
|
||||||
'.js': 'text/javascript',
|
|
||||||
'.css': 'text/css',
|
|
||||||
'.json': 'application/json',
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.svg': 'image/svg+xml',
|
|
||||||
'.ico': 'image/x-icon',
|
|
||||||
'.woff2': 'font/woff2',
|
|
||||||
'.woff': 'font/woff',
|
|
||||||
'.ttf': 'font/ttf',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Static File Server ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function startStaticServer() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = createServer((req, res) => {
|
|
||||||
let urlPath = req.url.split('?')[0];
|
|
||||||
if (urlPath === '/') urlPath = '/index.html';
|
|
||||||
|
|
||||||
const filePath = join(PUBLIC_DIR, urlPath);
|
|
||||||
|
|
||||||
if (!existsSync(filePath)) {
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end('Not Found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = readFileSync(filePath);
|
|
||||||
const ext = extname(filePath);
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
});
|
|
||||||
res.end(data);
|
|
||||||
} catch {
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end('Internal Server Error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
|
||||||
console.log(`Static server on http://localhost:${PORT}`);
|
|
||||||
resolve(server);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mock Data ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeSession(id, name, mode, status, extra = {}) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
pid: status === 'idle' ? null : 12345 + Math.floor(Math.random() * 10000),
|
|
||||||
status,
|
|
||||||
workingDir: '/home/user/my-project',
|
|
||||||
currentTaskId: null,
|
|
||||||
createdAt: Date.now() - 3600000,
|
|
||||||
lastActivityAt: Date.now() - (status === 'idle' ? 60000 : 5000),
|
|
||||||
name,
|
|
||||||
mode,
|
|
||||||
autoClearEnabled: false,
|
|
||||||
autoClearThreshold: 140000,
|
|
||||||
autoCompactEnabled: false,
|
|
||||||
autoCompactThreshold: 110000,
|
|
||||||
autoCompactPrompt: '',
|
|
||||||
imageWatcherEnabled: false,
|
|
||||||
totalCost: mode === 'claude' ? 0.12 : 0,
|
|
||||||
inputTokens: mode === 'claude' ? 18000 : 0,
|
|
||||||
outputTokens: mode === 'claude' ? 11500 : 0,
|
|
||||||
ralphEnabled: false,
|
|
||||||
niceEnabled: false,
|
|
||||||
niceValue: 10,
|
|
||||||
color: 'default',
|
|
||||||
flickerFilterEnabled: false,
|
|
||||||
cliVersion: '2.1.61',
|
|
||||||
cliModel: mode === 'claude' ? 'Opus 4.6' : 'GPT-4o',
|
|
||||||
cliAccountType: mode === 'claude' ? 'Claude Max' : 'API Key',
|
|
||||||
cliLatestVersion: '2.1.61',
|
|
||||||
messageCount: 15,
|
|
||||||
isWorking: status === 'busy',
|
|
||||||
lastPromptTime: Date.now() - 30000,
|
|
||||||
bufferStats: {
|
|
||||||
terminalBufferSize: 4096,
|
|
||||||
textOutputSize: 2048,
|
|
||||||
messageCount: 15,
|
|
||||||
maxTerminalBuffer: 2097152,
|
|
||||||
maxTextOutput: 1048576,
|
|
||||||
maxMessages: 1000,
|
|
||||||
},
|
|
||||||
taskStats: { total: 0, running: 0, completed: 0, failed: 0 },
|
|
||||||
taskTree: [],
|
|
||||||
tokens: {
|
|
||||||
input: mode === 'claude' ? 18000 : 0,
|
|
||||||
output: mode === 'claude' ? 11500 : 0,
|
|
||||||
total: mode === 'claude' ? 29500 : 0,
|
|
||||||
},
|
|
||||||
autoClear: { enabled: false, threshold: 140000 },
|
|
||||||
nice: { enabled: false, niceValue: 10 },
|
|
||||||
ralphLoop: null,
|
|
||||||
ralphTodos: [],
|
|
||||||
ralphTodoStats: { total: 0, completed: 0, percentComplete: 0 },
|
|
||||||
respawnEnabled: false,
|
|
||||||
respawnConfig: null,
|
|
||||||
respawn: null,
|
|
||||||
claudeSessionId: `claude-${id}`,
|
|
||||||
...extra,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInitPayload(sessions) {
|
|
||||||
const respawnStatus = {};
|
|
||||||
for (const s of sessions) {
|
|
||||||
respawnStatus[s.id] = {
|
|
||||||
state: 'idle',
|
|
||||||
cycleCount: 0,
|
|
||||||
lastActivityTime: Date.now(),
|
|
||||||
timeSinceActivity: 0,
|
|
||||||
promptDetected: false,
|
|
||||||
workingDetected: false,
|
|
||||||
detection: {},
|
|
||||||
config: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: '0.1651',
|
|
||||||
sessions,
|
|
||||||
scheduledRuns: [],
|
|
||||||
respawnStatus,
|
|
||||||
globalStats: {
|
|
||||||
totalInputTokens: 145000,
|
|
||||||
totalOutputTokens: 87000,
|
|
||||||
totalCost: 1.82,
|
|
||||||
totalSessionsCreated: 12,
|
|
||||||
firstRecordedAt: Date.now() - 86400000,
|
|
||||||
lastUpdatedAt: Date.now(),
|
|
||||||
},
|
|
||||||
subagents: [],
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Terminal Content (ANSI) ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const RST = '\x1b[0m';
|
|
||||||
const RED = '\x1b[31m';
|
|
||||||
const GRN = '\x1b[32m';
|
|
||||||
const YEL = '\x1b[33m';
|
|
||||||
const MAG = '\x1b[35m';
|
|
||||||
const GRY = '\x1b[90m';
|
|
||||||
const BOLD = '\x1b[1m';
|
|
||||||
const DIM = '\x1b[2m';
|
|
||||||
|
|
||||||
// Claude Code init banner
|
|
||||||
const TERMINAL_CLAUDE = [
|
|
||||||
'',
|
|
||||||
` ${RED}████${RST} ${BOLD}Claude Code${RST} v2.1.61`,
|
|
||||||
` ${RED}████${RST} ${GRY}Opus 4.6${RST} ${DIM}·${RST} ${GRY}Claude Max${RST}`,
|
|
||||||
` ${RED}██${RST} ${RED}██${RST} ${GRY}~/my-project${RST}`,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
`${GRY}❯ Try "how do I log an error?"${RST}`,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
`${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`,
|
|
||||||
` ${BOLD}0 tokens${RST}`,
|
|
||||||
` ${GRY}current: 2.1.61${RST} ${DIM}·${RST} ${GRY}latest: 2.1.61${RST}`,
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
// OpenCode init banner
|
|
||||||
const TERMINAL_OPENCODE = [
|
|
||||||
'',
|
|
||||||
` ${GRN}████${RST} ${BOLD}OpenCode${RST} v0.3.12`,
|
|
||||||
` ${GRN}████${RST} ${GRY}GPT-4o${RST} ${DIM}·${RST} ${GRY}API Key${RST}`,
|
|
||||||
` ${GRN}██${RST} ${GRN}██${RST} ${GRY}~/my-project${RST}`,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
`${GRY}❯ Try "explain this codebase"${RST}`,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
`${YEL}»»${RST} ${BOLD}bypass permissions on${RST}`,
|
|
||||||
` ${BOLD}0 tokens${RST}`,
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
// ─── Route Interceptors ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function setupRoutes(page, initPayload, terminalContent) {
|
|
||||||
// Block SSE to prevent reconnection loops
|
|
||||||
await page.route('**/api/events', async (route) => {
|
|
||||||
await route.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Terminal buffer endpoint
|
|
||||||
await page.route('**/api/sessions/*/terminal**', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
terminalBuffer: terminalContent,
|
|
||||||
status: 'busy',
|
|
||||||
fullSize: terminalContent.length,
|
|
||||||
truncated: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mux sessions
|
|
||||||
await page.route('**/api/mux-sessions/**', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ success: true }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route('**/api/mux-sessions', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ sessions: [], muxAvailable: true }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
await page.route('**/api/settings', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
showSubagents: false,
|
|
||||||
subagentTrackingEnabled: false,
|
|
||||||
subagentActiveTabOnly: false,
|
|
||||||
showMonitor: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subagent window states
|
|
||||||
await page.route('**/api/subagent-window-states', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subagent parents
|
|
||||||
await page.route('**/api/subagent-parents', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Session-specific subagents
|
|
||||||
await page.route('**/api/sessions/*/subagents', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ success: true, data: [] }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Interactive attach
|
|
||||||
await page.route('**/api/sessions/*/interactive', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ success: true }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resize
|
|
||||||
await page.route('**/api/sessions/*/resize', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ success: true }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Catch-all API
|
|
||||||
await page.route('**/api/**', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ success: true }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject mock state and render the app.
|
|
||||||
*/
|
|
||||||
async function injectState(page, initPayload, terminalContent, activeSessionId) {
|
|
||||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 });
|
|
||||||
await sleep(2000);
|
|
||||||
|
|
||||||
// Cancel SSE fallback timer and inject state
|
|
||||||
await page.evaluate((payload) => {
|
|
||||||
const app = window.app;
|
|
||||||
if (app._initFallbackTimer) {
|
|
||||||
clearTimeout(app._initFallbackTimer);
|
|
||||||
app._initFallbackTimer = null;
|
|
||||||
}
|
|
||||||
app.handleInit(payload);
|
|
||||||
}, initPayload);
|
|
||||||
|
|
||||||
await sleep(3000);
|
|
||||||
|
|
||||||
// Select the target session
|
|
||||||
if (activeSessionId) {
|
|
||||||
await page.evaluate((sid) => {
|
|
||||||
window.app.activeSessionId = null;
|
|
||||||
window.app.selectSession(sid);
|
|
||||||
}, activeSessionId);
|
|
||||||
await sleep(4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write terminal content directly as backup
|
|
||||||
if (terminalContent && activeSessionId) {
|
|
||||||
await page.evaluate((content) => {
|
|
||||||
const app = window.app;
|
|
||||||
if (app.terminal) {
|
|
||||||
const buf = app.terminal.buffer?.active;
|
|
||||||
const hasContent = buf && buf.length > 2 && buf.getLine(1)?.translateToString().trim();
|
|
||||||
if (!hasContent) {
|
|
||||||
app.terminal.clear();
|
|
||||||
app.terminal.reset();
|
|
||||||
app.terminal.write(content);
|
|
||||||
app.terminal.scrollToBottom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, terminalContent);
|
|
||||||
await sleep(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up UI artifacts
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const app = window.app;
|
|
||||||
if (app) {
|
|
||||||
if (app.sseReconnectTimeout) clearTimeout(app.sseReconnectTimeout);
|
|
||||||
if (app.eventSource) { app.eventSource.close(); app.eventSource = null; }
|
|
||||||
app._connectionStatus = 'connected';
|
|
||||||
app._updateConnectionIndicator = () => {};
|
|
||||||
app.connectSSE = () => {};
|
|
||||||
app.setConnectionStatus = () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove connection indicator
|
|
||||||
const indicator = document.getElementById('connectionIndicator');
|
|
||||||
if (indicator) indicator.remove();
|
|
||||||
|
|
||||||
// Hide respawn banner
|
|
||||||
const respawnBanner = document.getElementById('respawnBanner');
|
|
||||||
if (respawnBanner) respawnBanner.style.display = 'none';
|
|
||||||
|
|
||||||
// Mock CPU/MEM stats
|
|
||||||
const statsEl = document.getElementById('headerSystemStats');
|
|
||||||
if (statsEl) {
|
|
||||||
statsEl.innerHTML = `
|
|
||||||
<span class="stat-label">CPU</span>
|
|
||||||
<div class="stat-bar"><div class="stat-bar-fill cpu-bar" style="width: 18%"></div></div>
|
|
||||||
<span class="stat-value">18%</span>
|
|
||||||
<span class="stat-label">MEM</span>
|
|
||||||
<div class="stat-bar"><div class="stat-bar-fill mem-bar" style="width: 42%"></div></div>
|
|
||||||
<span class="stat-value">13.7G</span>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide subagents panel entirely
|
|
||||||
const subagentsPanel = document.getElementById('subagentsPanel');
|
|
||||||
if (subagentsPanel) subagentsPanel.style.display = 'none';
|
|
||||||
|
|
||||||
// Close and hide monitor panel
|
|
||||||
const monitorPanel = document.getElementById('monitorPanel');
|
|
||||||
if (monitorPanel) {
|
|
||||||
monitorPanel.classList.remove('open');
|
|
||||||
monitorPanel.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide monitor toggle button in toolbar
|
|
||||||
const monitorBtn = document.getElementById('monitorToggle');
|
|
||||||
if (monitorBtn) monitorBtn.style.display = 'none';
|
|
||||||
|
|
||||||
// Update token display
|
|
||||||
if (app && app.activeSessionId) {
|
|
||||||
const session = app.sessions.get(app.activeSessionId);
|
|
||||||
if (session) {
|
|
||||||
const tokens = (session.tokens?.total || 0);
|
|
||||||
const tokenEl = document.getElementById('headerTokens');
|
|
||||||
if (tokenEl) {
|
|
||||||
const formatted = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
||||||
tokenEl.innerHTML = `<span class="token-icon">⊙</span> <span class="token-count">${formatted} tokens</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Final settle — let xterm.js WebGL renderer, fonts, and layout fully stabilize
|
|
||||||
await sleep(4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Screenshot Scenarios ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// 1. Desktop welcome — no sessions, welcome overlay visible
|
|
||||||
async function captureDesktopWelcome(browser) {
|
|
||||||
console.log('\n1/6 Capturing desktop-welcome.png...');
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: DESKTOP_VIEWPORT,
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(30000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const initPayload = buildInitPayload([]);
|
|
||||||
await setupRoutes(page, initPayload, '');
|
|
||||||
|
|
||||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 });
|
|
||||||
await sleep(2000);
|
|
||||||
|
|
||||||
// Inject empty state — welcome overlay should show automatically
|
|
||||||
await page.evaluate((payload) => {
|
|
||||||
const app = window.app;
|
|
||||||
if (app._initFallbackTimer) {
|
|
||||||
clearTimeout(app._initFallbackTimer);
|
|
||||||
app._initFallbackTimer = null;
|
|
||||||
}
|
|
||||||
app.handleInit(payload);
|
|
||||||
}, initPayload);
|
|
||||||
await sleep(3000);
|
|
||||||
|
|
||||||
// Clean up UI
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const app = window.app;
|
|
||||||
if (app) {
|
|
||||||
if (app.sseReconnectTimeout) clearTimeout(app.sseReconnectTimeout);
|
|
||||||
if (app.eventSource) { app.eventSource.close(); app.eventSource = null; }
|
|
||||||
app._connectionStatus = 'connected';
|
|
||||||
app._updateConnectionIndicator = () => {};
|
|
||||||
app.connectSSE = () => {};
|
|
||||||
app.setConnectionStatus = () => {};
|
|
||||||
}
|
|
||||||
const indicator = document.getElementById('connectionIndicator');
|
|
||||||
if (indicator) indicator.remove();
|
|
||||||
const respawnBanner = document.getElementById('respawnBanner');
|
|
||||||
if (respawnBanner) respawnBanner.style.display = 'none';
|
|
||||||
|
|
||||||
// Mock stats
|
|
||||||
const statsEl = document.getElementById('headerSystemStats');
|
|
||||||
if (statsEl) {
|
|
||||||
statsEl.innerHTML = `
|
|
||||||
<span class="stat-label">CPU</span>
|
|
||||||
<div class="stat-bar"><div class="stat-bar-fill cpu-bar" style="width: 18%"></div></div>
|
|
||||||
<span class="stat-value">18%</span>
|
|
||||||
<span class="stat-label">MEM</span>
|
|
||||||
<div class="stat-bar"><div class="stat-bar-fill mem-bar" style="width: 42%"></div></div>
|
|
||||||
<span class="stat-value">13.7G</span>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide monitor and subagent panels
|
|
||||||
const subagentsPanel = document.getElementById('subagentsPanel');
|
|
||||||
if (subagentsPanel) subagentsPanel.style.display = 'none';
|
|
||||||
const monitorPanel = document.getElementById('monitorPanel');
|
|
||||||
if (monitorPanel) {
|
|
||||||
monitorPanel.classList.remove('open');
|
|
||||||
monitorPanel.style.display = 'none';
|
|
||||||
}
|
|
||||||
const monitorBtn = document.getElementById('monitorToggle');
|
|
||||||
if (monitorBtn) monitorBtn.style.display = 'none';
|
|
||||||
});
|
|
||||||
await sleep(3000);
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: join(OUTPUT_DIR, 'desktop-welcome.png'),
|
|
||||||
fullPage: false,
|
|
||||||
});
|
|
||||||
console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
|
|
||||||
} finally {
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Desktop with single Claude tab active
|
|
||||||
async function captureDesktopClaude(browser) {
|
|
||||||
console.log('\n2/6 Capturing desktop-claude.png...');
|
|
||||||
|
|
||||||
const claudeSession = makeSession('sess-claude-1', 'w1-my-project', 'claude', 'busy');
|
|
||||||
const initPayload = buildInitPayload([claudeSession]);
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: DESKTOP_VIEWPORT,
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(30000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
|
|
||||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-1');
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: join(OUTPUT_DIR, 'desktop-claude.png'),
|
|
||||||
fullPage: false,
|
|
||||||
});
|
|
||||||
console.log(' Saved: scripts/remotion/public/desktop-claude.png');
|
|
||||||
} finally {
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Desktop with 2 tabs (Claude + OpenCode), Claude active
|
|
||||||
async function captureDesktopBothClaude(browser) {
|
|
||||||
console.log('\n3/6 Capturing desktop-both-claude.png...');
|
|
||||||
|
|
||||||
const claudeSession = makeSession('sess-claude-2', 'w1-my-project', 'claude', 'busy');
|
|
||||||
const opencodeSession = makeSession('sess-opencode-2', 'w2-my-project', 'opencode', 'busy');
|
|
||||||
const initPayload = buildInitPayload([claudeSession, opencodeSession]);
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: DESKTOP_VIEWPORT,
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(30000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
|
|
||||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-2');
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
|
|
||||||
fullPage: false,
|
|
||||||
});
|
|
||||||
console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
|
|
||||||
} finally {
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Desktop with 2 tabs (Claude + OpenCode), OpenCode active
|
|
||||||
async function captureDesktopBothOpencode(browser) {
|
|
||||||
console.log('\n4/6 Capturing desktop-both-opencode.png...');
|
|
||||||
|
|
||||||
const claudeSession = makeSession('sess-claude-3', 'w1-my-project', 'claude', 'busy');
|
|
||||||
const opencodeSession = makeSession('sess-opencode-3', 'w2-my-project', 'opencode', 'busy');
|
|
||||||
const initPayload = buildInitPayload([claudeSession, opencodeSession]);
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: DESKTOP_VIEWPORT,
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(30000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await setupRoutes(page, initPayload, TERMINAL_OPENCODE);
|
|
||||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await injectState(page, initPayload, TERMINAL_OPENCODE, 'sess-opencode-3');
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
|
|
||||||
fullPage: false,
|
|
||||||
});
|
|
||||||
console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
|
|
||||||
} finally {
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Mobile with Claude tab
|
|
||||||
async function captureMobileClaude(browser) {
|
|
||||||
console.log('\n5/6 Capturing mobile-claude.png...');
|
|
||||||
|
|
||||||
const claudeSession = makeSession('sess-claude-m1', 'w1-my-project', 'claude', 'busy');
|
|
||||||
const initPayload = buildInitPayload([claudeSession]);
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: MOBILE_VIEWPORT,
|
|
||||||
deviceScaleFactor: 2,
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(30000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
|
|
||||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-m1');
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: join(OUTPUT_DIR, 'mobile-claude.png'),
|
|
||||||
fullPage: false,
|
|
||||||
});
|
|
||||||
console.log(' Saved: scripts/remotion/public/mobile-claude.png');
|
|
||||||
} finally {
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Mobile with OpenCode tab
|
|
||||||
async function captureMobileOpencode(browser) {
|
|
||||||
console.log('\n6/6 Capturing mobile-opencode.png...');
|
|
||||||
|
|
||||||
const opencodeSession = makeSession('sess-opencode-m1', 'w1-my-project', 'opencode', 'busy');
|
|
||||||
const initPayload = buildInitPayload([opencodeSession]);
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: MOBILE_VIEWPORT,
|
|
||||||
deviceScaleFactor: 2,
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
page.setDefaultTimeout(30000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await setupRoutes(page, initPayload, TERMINAL_OPENCODE);
|
|
||||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await injectState(page, initPayload, TERMINAL_OPENCODE, 'sess-opencode-m1');
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: join(OUTPUT_DIR, 'mobile-opencode.png'),
|
|
||||||
fullPage: false,
|
|
||||||
});
|
|
||||||
console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
|
|
||||||
} finally {
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
console.log('Codeman Video Screenshot Capture');
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
console.log(`Port: ${PORT}`);
|
|
||||||
console.log(`Desktop: ${DESKTOP_VIEWPORT.width}x${DESKTOP_VIEWPORT.height}`);
|
|
||||||
console.log(`Mobile: ${MOBILE_VIEWPORT.width}x${MOBILE_VIEWPORT.height} @2x`);
|
|
||||||
console.log(`Output: ${OUTPUT_DIR}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
const server = await startStaticServer();
|
|
||||||
let browser;
|
|
||||||
|
|
||||||
try {
|
|
||||||
browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
args: [
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
'--disable-gpu',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await captureDesktopWelcome(browser);
|
|
||||||
await captureDesktopClaude(browser);
|
|
||||||
await captureDesktopBothClaude(browser);
|
|
||||||
await captureDesktopBothOpencode(browser);
|
|
||||||
await captureMobileClaude(browser);
|
|
||||||
await captureMobileOpencode(browser);
|
|
||||||
|
|
||||||
console.log('\n' + '='.repeat(60));
|
|
||||||
console.log('All 6 screenshots captured!');
|
|
||||||
console.log('='.repeat(60));
|
|
||||||
console.log('\nOutput files:');
|
|
||||||
console.log(' scripts/remotion/public/desktop-welcome.png');
|
|
||||||
console.log(' scripts/remotion/public/desktop-claude.png');
|
|
||||||
console.log(' scripts/remotion/public/desktop-both-claude.png');
|
|
||||||
console.log(' scripts/remotion/public/desktop-both-opencode.png');
|
|
||||||
console.log(' scripts/remotion/public/mobile-claude.png');
|
|
||||||
console.log(' scripts/remotion/public/mobile-opencode.png');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('\nFatal error:', err.message);
|
|
||||||
console.error(err.stack);
|
|
||||||
process.exitCode = 1;
|
|
||||||
} finally {
|
|
||||||
if (browser) await browser.close().catch(() => {});
|
|
||||||
server.close();
|
|
||||||
console.log('\nDone.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('\nInterrupted.');
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Frontend dead-code check.
|
||||||
|
*
|
||||||
|
* `src/web/public/*.js` is loaded as plain <script> files: no imports, so knip cannot
|
||||||
|
* follow it, and it is outside ESLint. A method that lost its last caller therefore
|
||||||
|
* stays in the tree unnoticed (the November 2026 cleanup removed about 2,000 such
|
||||||
|
* lines of JS and 1,500 lines of CSS). This script finds them.
|
||||||
|
*
|
||||||
|
* JavaScript: every function, method and top-level binding the frontend files
|
||||||
|
* define, checked against every identifier and every string or template literal in
|
||||||
|
* those files, `index.html` (inline handlers), the gesture overlay's source and the
|
||||||
|
* tests. A name that appears nowhere but at its own definition is reported.
|
||||||
|
*
|
||||||
|
* CSS: every class and id selector in `styles.css` and `mobile.css`, checked against
|
||||||
|
* the same text. A class built at runtime (`tab-state-${state}`) is recognised by its
|
||||||
|
* prefix and skipped, so the report holds only names no code can produce.
|
||||||
|
*
|
||||||
|
* Text matching is deliberately conservative: a mention in a comment does not count,
|
||||||
|
* a mention in any string does. So a clean run does not prove everything is used,
|
||||||
|
* but a reported name really has no reference. To keep one on purpose, add it to
|
||||||
|
* ALLOW below with the reason.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/check-frontend-dead-code.mjs [--production]
|
||||||
|
* --production also report what only tests reference (informational, exit 0)
|
||||||
|
*
|
||||||
|
* Parsed with the TypeScript compiler (already a dev dependency), so this needs no
|
||||||
|
* package of its own.
|
||||||
|
*/
|
||||||
|
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||||
|
import { join, dirname, relative } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const PUBLIC_DIR = join(ROOT, 'src', 'web', 'public');
|
||||||
|
const require = createRequire(join(ROOT, 'package.json'));
|
||||||
|
const ts = require('typescript');
|
||||||
|
const postcss = require('postcss');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names kept on purpose although nothing references them. Key: the name as reported
|
||||||
|
* (a JS name, or a selector with its `.` or `#`). Value: why it stays.
|
||||||
|
*/
|
||||||
|
const ALLOW = new Map([
|
||||||
|
// ['exampleMethod', 'called from the browser console when debugging X'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Class prefixes that belong to vendored code, which the scan does not read. */
|
||||||
|
const EXTERNAL_CLASS_PREFIXES = ['xterm'];
|
||||||
|
|
||||||
|
const productionMode = process.argv.includes('--production');
|
||||||
|
|
||||||
|
function walk(dir, filter, skipDirs = []) {
|
||||||
|
const out = [];
|
||||||
|
if (!existsSync(dir)) return out;
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
if (entry === 'node_modules' || skipDirs.includes(entry)) continue;
|
||||||
|
const path = join(dir, entry);
|
||||||
|
if (statSync(path).isDirectory()) out.push(...walk(path, filter, skipDirs));
|
||||||
|
else if (filter.test(entry)) out.push(path);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rel = (path) => relative(ROOT, path);
|
||||||
|
const read = (path) => readFileSync(path, 'utf8');
|
||||||
|
|
||||||
|
// ── What counts as a reference ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Shipped scripts. The vendored bundles and the generated gesture bundle are not ours
|
||||||
|
// to judge; the gesture overlay is read from its source instead.
|
||||||
|
const frontendFiles = walk(PUBLIC_DIR, /\.js$/, ['vendor', 'gesture', 'fonts']);
|
||||||
|
const htmlText = walk(PUBLIC_DIR, /\.html$/, ['vendor', 'gesture']).map(read).join('\n');
|
||||||
|
const gestureText = walk(join(ROOT, 'packages', 'gesture-control', 'src'), /\.(ts|js)$/)
|
||||||
|
.map(read)
|
||||||
|
.join('\n');
|
||||||
|
const serverText = walk(join(ROOT, 'src'), /\.ts$/, ['public']).map(read).join('\n');
|
||||||
|
const overlayText = walk(join(ROOT, 'packages', 'xterm-zerolag-input', 'src'), /\.(ts|js)$/)
|
||||||
|
.map(read)
|
||||||
|
.join('\n');
|
||||||
|
const testText = walk(join(ROOT, 'test'), /\.(ts|mjs|js)$/).map(read).join('\n');
|
||||||
|
|
||||||
|
const identifierCount = new Map();
|
||||||
|
const literalParts = [];
|
||||||
|
const definitions = [];
|
||||||
|
|
||||||
|
function bump(name) {
|
||||||
|
identifierCount.set(name, (identifierCount.get(name) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFunctionLike(node) {
|
||||||
|
return !!node && (ts.isFunctionExpression(node) || ts.isArrowFunction(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyName(name) {
|
||||||
|
if (!name) return null;
|
||||||
|
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-level statements, looking one level into an IIFE wrapper. */
|
||||||
|
function topLevelStatements(sourceFile) {
|
||||||
|
const out = [...sourceFile.statements];
|
||||||
|
for (const statement of sourceFile.statements) {
|
||||||
|
if (!ts.isExpressionStatement(statement)) continue;
|
||||||
|
let expr = statement.expression;
|
||||||
|
while (ts.isParenthesizedExpression(expr) || ts.isPrefixUnaryExpression(expr)) {
|
||||||
|
expr = ts.isParenthesizedExpression(expr) ? expr.expression : expr.operand;
|
||||||
|
}
|
||||||
|
if (!ts.isCallExpression(expr)) continue;
|
||||||
|
let callee = expr.expression;
|
||||||
|
while (ts.isParenthesizedExpression(callee)) callee = callee.expression;
|
||||||
|
if (ts.isPropertyAccessExpression(callee)) callee = callee.expression;
|
||||||
|
while (ts.isParenthesizedExpression(callee)) callee = callee.expression;
|
||||||
|
if ((ts.isFunctionExpression(callee) || ts.isArrowFunction(callee)) && ts.isBlock(callee.body)) {
|
||||||
|
out.push(...callee.body.statements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of frontendFiles) {
|
||||||
|
const text = read(file);
|
||||||
|
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
||||||
|
const lineOf = (node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
||||||
|
const define = (name, node, kind) => {
|
||||||
|
if (name) definitions.push({ name, file: rel(file), line: lineOf(node), kind });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const statement of topLevelStatements(sourceFile)) {
|
||||||
|
if (ts.isVariableStatement(statement)) {
|
||||||
|
for (const decl of statement.declarationList.declarations) {
|
||||||
|
if (ts.isIdentifier(decl.name)) define(decl.name.text, decl, 'binding');
|
||||||
|
}
|
||||||
|
} else if (ts.isClassDeclaration(statement) && statement.name) {
|
||||||
|
define(statement.name.text, statement, 'binding');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visit = (node) => {
|
||||||
|
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) bump(node.text);
|
||||||
|
else if (
|
||||||
|
ts.isStringLiteral(node) ||
|
||||||
|
ts.isNoSubstitutionTemplateLiteral(node) ||
|
||||||
|
node.kind === ts.SyntaxKind.TemplateHead ||
|
||||||
|
node.kind === ts.SyntaxKind.TemplateMiddle ||
|
||||||
|
node.kind === ts.SyntaxKind.TemplateTail
|
||||||
|
) {
|
||||||
|
literalParts.push(node.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ts.isFunctionDeclaration(node) && node.name) define(node.name.text, node, 'function');
|
||||||
|
else if (ts.isMethodDeclaration(node) || ts.isGetAccessor(node) || ts.isSetAccessor(node)) {
|
||||||
|
define(propertyName(node.name), node, 'method');
|
||||||
|
} else if (ts.isPropertyAssignment(node) && isFunctionLike(node.initializer)) {
|
||||||
|
define(propertyName(node.name), node, 'method');
|
||||||
|
} else if (ts.isPropertyDeclaration(node) && isFunctionLike(node.initializer)) {
|
||||||
|
define(propertyName(node.name), node, 'method');
|
||||||
|
} else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && isFunctionLike(node.initializer)) {
|
||||||
|
define(node.name.text, node, 'function');
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
};
|
||||||
|
visit(sourceFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const literalText = literalParts.join('\n');
|
||||||
|
const wordPattern = (name) => new RegExp(`(?<![\\w$])${name.replace(/[$]/g, '\\$&')}(?![\\w$])`, 'g');
|
||||||
|
const count = (text, pattern) => (text.match(pattern) ?? []).length;
|
||||||
|
|
||||||
|
// ── JavaScript ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const byName = new Map();
|
||||||
|
for (const def of definitions) {
|
||||||
|
// A binding and a function for the same declaration are one definition.
|
||||||
|
const list = byName.get(def.name) ?? [];
|
||||||
|
if (!list.some((d) => d.file === def.file && d.line === def.line)) list.push(def);
|
||||||
|
byName.set(def.name, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadJs = [];
|
||||||
|
const testOnlyJs = [];
|
||||||
|
for (const [name, defs] of byName) {
|
||||||
|
if (name.length < 3 || name === 'constructor' || ALLOW.has(name)) continue;
|
||||||
|
const pattern = wordPattern(name);
|
||||||
|
const shipped =
|
||||||
|
(identifierCount.get(name) ?? 0) -
|
||||||
|
defs.length +
|
||||||
|
count(literalText, pattern) +
|
||||||
|
count(htmlText, pattern) +
|
||||||
|
count(gestureText, pattern);
|
||||||
|
if (shipped > 0) continue;
|
||||||
|
const inTests = count(testText, pattern);
|
||||||
|
(inTests > 0 ? testOnlyJs : deadJs).push({ name, defs, inTests });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSS ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const cssCorpus = [
|
||||||
|
...frontendFiles.map(read),
|
||||||
|
htmlText,
|
||||||
|
gestureText,
|
||||||
|
overlayText,
|
||||||
|
serverText,
|
||||||
|
...walk(PUBLIC_DIR, /\.json$/, ['vendor', 'gesture']).map(read),
|
||||||
|
].join('\n');
|
||||||
|
const corpusWords = new Set(cssCorpus.match(/[A-Za-z_][\w-]*/g) ?? []);
|
||||||
|
// `foo-${x}` and 'foo-' + x build a class at runtime: everything with that prefix may exist.
|
||||||
|
const dynamicPrefixes = new Set();
|
||||||
|
for (const m of cssCorpus.matchAll(/([A-Za-z_][\w-]*-)(?:\$\{|['"`]\s*\+)/g)) dynamicPrefixes.add(m[1]);
|
||||||
|
// `${x}-suffix` and x + '-suffix' likewise, when the stem itself is a known word.
|
||||||
|
const dynamicSuffixes = new Set();
|
||||||
|
for (const m of cssCorpus.matchAll(/(?:\}|\+\s*['"`])(-[\w-]+)/g)) dynamicSuffixes.add(m[1]);
|
||||||
|
|
||||||
|
const deadCss = [];
|
||||||
|
for (const cssFile of [join(PUBLIC_DIR, 'styles.css'), join(PUBLIC_DIR, 'mobile.css')]) {
|
||||||
|
const seen = new Map();
|
||||||
|
postcss.parse(read(cssFile), { from: cssFile }).walkRules((rule) => {
|
||||||
|
const selector = rule.selector.replace(/\[[^\]]*\]/g, ' ').replace(/"[^"]*"|'[^']*'/g, ' ');
|
||||||
|
for (const m of selector.matchAll(/([.#])(-?[A-Za-z_][\w-]*)/g)) {
|
||||||
|
const key = m[1] + m[2];
|
||||||
|
if (!seen.has(key)) seen.set(key, rule.source.start.line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const [key, line] of seen) {
|
||||||
|
const name = key.slice(1);
|
||||||
|
if (ALLOW.has(key) || corpusWords.has(name)) continue;
|
||||||
|
if (EXTERNAL_CLASS_PREFIXES.some((prefix) => name === prefix || name.startsWith(prefix + '-'))) continue;
|
||||||
|
if ([...dynamicPrefixes].some((prefix) => name.startsWith(prefix))) continue;
|
||||||
|
if ([...dynamicSuffixes].some((suffix) => name.endsWith(suffix) && corpusWords.has(name.slice(0, -suffix.length)))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (deadCss.some((d) => d.key === key)) continue;
|
||||||
|
deadCss.push({ key, file: rel(cssFile), line });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Report ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const where = (defs) => defs.map((d) => `${d.file}:${d.line}`).join(', ');
|
||||||
|
const sortByPlace = (a, b) => where(a.defs).localeCompare(where(b.defs), undefined, { numeric: true });
|
||||||
|
|
||||||
|
if (productionMode && testOnlyJs.length > 0) {
|
||||||
|
console.log(`Referenced by tests only (${testOnlyJs.length}):`);
|
||||||
|
for (const item of testOnlyJs.sort(sortByPlace)) {
|
||||||
|
console.log(` ${item.name} ${where(item.defs)} (${item.inTests} mention${item.inTests === 1 ? '' : 's'} in test/)`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deadJs.length === 0 && deadCss.length === 0) {
|
||||||
|
console.log(
|
||||||
|
`✓ no unreferenced frontend code: ${byName.size} names in ${frontendFiles.length} scripts and the selectors of 2 stylesheets checked`
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deadJs.length > 0) {
|
||||||
|
console.error(`Unreferenced frontend functions and bindings (${deadJs.length}):`);
|
||||||
|
for (const item of deadJs.sort(sortByPlace)) console.error(` ${item.name} ${where(item.defs)}`);
|
||||||
|
}
|
||||||
|
if (deadCss.length > 0) {
|
||||||
|
console.error(`${deadJs.length > 0 ? '\n' : ''}CSS selectors nothing can produce (${deadCss.length}):`);
|
||||||
|
for (const item of deadCss) console.error(` ${item.key} ${item.file}:${item.line}`);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
'\nRemove each one, or add it to ALLOW in scripts/check-frontend-dead-code.mjs with the reason it stays.'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Generates timestamped data to a file for testing
|
|
||||||
# Usage: ./scripts/data-generator.sh [output_file] [interval_seconds]
|
|
||||||
# Then: tail -f /tmp/test-data.log
|
|
||||||
|
|
||||||
OUTPUT="${1:-/tmp/test-data.log}"
|
|
||||||
INTERVAL="${2:-1}"
|
|
||||||
|
|
||||||
echo "Writing to $OUTPUT every ${INTERVAL}s (Ctrl+C to stop)"
|
|
||||||
echo "Run: tail -f $OUTPUT"
|
|
||||||
|
|
||||||
i=0
|
|
||||||
while true; do
|
|
||||||
((i++))
|
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Line $i - Random: $RANDOM" >> "$OUTPUT"
|
|
||||||
sleep "$INTERVAL"
|
|
||||||
done
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Take mobile screenshots of Codeman using Playwright
|
|
||||||
* Emulates iPhone 15 Pro (closest to iPhone 17 Pro)
|
|
||||||
* Includes keyboard simulation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium, devices } from 'playwright';
|
|
||||||
import { join, dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { mkdirSync } from 'fs';
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const BASE_URL = 'http://localhost:3000';
|
|
||||||
const OUTPUT_DIR = join(__dirname, '..', 'docs', 'screenshots');
|
|
||||||
|
|
||||||
// Ensure output directory exists
|
|
||||||
try { mkdirSync(OUTPUT_DIR, { recursive: true }); } catch {}
|
|
||||||
|
|
||||||
// iPhone 15 Pro specs (closest to requested iPhone 17 Pro)
|
|
||||||
// Screen: 393 x 852 at 3x = 1179 x 2556 physical pixels
|
|
||||||
const VIEWPORT_WIDTH = 393;
|
|
||||||
const VIEWPORT_HEIGHT = 852;
|
|
||||||
const KEYBOARD_HEIGHT = 336; // Typical iOS keyboard height
|
|
||||||
|
|
||||||
const iPhone15Pro = {
|
|
||||||
...devices['iPhone 14 Pro'],
|
|
||||||
viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
|
|
||||||
deviceScaleFactor: 3,
|
|
||||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
|
||||||
};
|
|
||||||
|
|
||||||
async function takeScreenshots() {
|
|
||||||
console.log('Launching browser with iPhone 15 Pro emulation...');
|
|
||||||
console.log('Output directory:', OUTPUT_DIR);
|
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext({
|
|
||||||
...iPhone15Pro,
|
|
||||||
hasTouch: true,
|
|
||||||
isMobile: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Screenshot 1: Main view (no keyboard)
|
|
||||||
console.log('Loading main page...');
|
|
||||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: join(OUTPUT_DIR, 'mobile-no-keyboard.png'),
|
|
||||||
fullPage: false
|
|
||||||
});
|
|
||||||
console.log('✓ Saved: mobile-no-keyboard.png');
|
|
||||||
|
|
||||||
// Screenshot 2: Simulate keyboard appearing
|
|
||||||
console.log('Simulating virtual keyboard...');
|
|
||||||
|
|
||||||
// Trigger the keyboard handler manually - simulate what happens when keyboard shows
|
|
||||||
await page.evaluate((keyboardHeight) => {
|
|
||||||
// Add keyboard-visible class
|
|
||||||
document.body.classList.add('keyboard-visible');
|
|
||||||
|
|
||||||
// Move toolbar up by keyboard height
|
|
||||||
const toolbar = document.querySelector('.toolbar');
|
|
||||||
if (toolbar) {
|
|
||||||
toolbar.style.transform = `translateY(${-keyboardHeight}px)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show and move accessory bar
|
|
||||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
|
||||||
if (accessoryBar) {
|
|
||||||
accessoryBar.classList.add('visible');
|
|
||||||
accessoryBar.style.transform = `translateY(${-keyboardHeight}px)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shrink main content area so terminal doesn't extend behind keyboard
|
|
||||||
// Account for toolbar (40px) + accessory bar (44px)
|
|
||||||
const main = document.querySelector('.main');
|
|
||||||
if (main) {
|
|
||||||
main.style.paddingBottom = `${keyboardHeight + 94}px`;
|
|
||||||
}
|
|
||||||
}, KEYBOARD_HEIGHT);
|
|
||||||
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Take screenshot of the reduced viewport (simulating keyboard up)
|
|
||||||
await page.screenshot({
|
|
||||||
path: join(OUTPUT_DIR, 'mobile-keyboard-visible.png'),
|
|
||||||
fullPage: false
|
|
||||||
});
|
|
||||||
console.log('✓ Saved: mobile-keyboard-visible.png');
|
|
||||||
|
|
||||||
// Screenshot 3: Full view with keyboard overlay visualization
|
|
||||||
// Reset viewport but keep toolbar position to show the effect
|
|
||||||
await page.setViewportSize({
|
|
||||||
width: VIEWPORT_WIDTH,
|
|
||||||
height: VIEWPORT_HEIGHT
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a visual keyboard overlay
|
|
||||||
await page.evaluate((keyboardHeight) => {
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.id = 'keyboard-overlay';
|
|
||||||
overlay.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: ${keyboardHeight}px;
|
|
||||||
background: linear-gradient(to bottom, #2a2a2a 0%, #1a1a1a 100%);
|
|
||||||
border-top: 1px solid #444;
|
|
||||||
z-index: 9999;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-content: flex-start;
|
|
||||||
padding: 8px 4px;
|
|
||||||
gap: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add fake keyboard keys
|
|
||||||
const rows = [
|
|
||||||
'Q W E R T Y U I O P',
|
|
||||||
'A S D F G H J K L',
|
|
||||||
'⇧ Z X C V B N M ⌫',
|
|
||||||
'123 🌐 space return'
|
|
||||||
];
|
|
||||||
|
|
||||||
rows.forEach((row, rowIndex) => {
|
|
||||||
const rowDiv = document.createElement('div');
|
|
||||||
rowDiv.style.cssText = 'display: flex; width: 100%; justify-content: center; gap: 4px;';
|
|
||||||
|
|
||||||
row.split(' ').forEach(key => {
|
|
||||||
const keyEl = document.createElement('div');
|
|
||||||
const isSpecial = ['⇧', '⌫', '123', '🌐', 'space', 'return'].includes(key);
|
|
||||||
const isSpace = key === 'space';
|
|
||||||
const isReturn = key === 'return';
|
|
||||||
|
|
||||||
keyEl.style.cssText = `
|
|
||||||
background: ${isSpecial ? '#3a3a3a' : '#505050'};
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: ${isSpace ? '10px 60px' : isReturn ? '10px 20px' : '10px 8px'};
|
|
||||||
font-size: ${rowIndex === 3 ? '12px' : '14px'};
|
|
||||||
min-width: ${isSpace ? '120px' : isReturn ? '60px' : '28px'};
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 1px 0 #222;
|
|
||||||
`;
|
|
||||||
keyEl.textContent = isSpace ? '' : key;
|
|
||||||
rowDiv.appendChild(keyEl);
|
|
||||||
});
|
|
||||||
|
|
||||||
overlay.appendChild(rowDiv);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
}, KEYBOARD_HEIGHT);
|
|
||||||
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: join(OUTPUT_DIR, 'mobile-with-keyboard.png'),
|
|
||||||
fullPage: false
|
|
||||||
});
|
|
||||||
console.log('✓ Saved: mobile-with-keyboard.png');
|
|
||||||
|
|
||||||
// Screenshot 4: Toolbar close-up with keyboard
|
|
||||||
const toolbar = page.locator('.toolbar');
|
|
||||||
if (await toolbar.count() > 0) {
|
|
||||||
await toolbar.screenshot({
|
|
||||||
path: join(OUTPUT_DIR, 'mobile-toolbar-above-keyboard.png')
|
|
||||||
});
|
|
||||||
console.log('✓ Saved: mobile-toolbar-above-keyboard.png');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✅ All screenshots saved to:', OUTPUT_DIR);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error taking screenshots:', error.message);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
takeScreenshots();
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Respawn Controller Monitor - 12 hour monitoring script
|
|
||||||
# Usage: ./scripts/monitor-respawn.sh <session_id> [duration_hours]
|
|
||||||
|
|
||||||
SESSION_ID="${1:-5ecdf859-3605-42d8-b0a2-f8f0a1b5ff98}"
|
|
||||||
DURATION_HOURS="${2:-12}"
|
|
||||||
ISSUES_FILE="/home/arkon/default/codeman/respawn-monitor-issues.md"
|
|
||||||
FIXES_FILE="/home/arkon/default/codeman/respawn-monitor-fixes.md"
|
|
||||||
API_URL="https://localhost:3000"
|
|
||||||
POLL_INTERVAL=60 # seconds
|
|
||||||
|
|
||||||
# Calculate end time
|
|
||||||
END_TIME=$(($(date +%s) + DURATION_HOURS * 3600))
|
|
||||||
|
|
||||||
log_issue() {
|
|
||||||
local severity="$1"
|
|
||||||
local message="$2"
|
|
||||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
||||||
echo "" >> "$ISSUES_FILE"
|
|
||||||
echo "### [$severity] $timestamp" >> "$ISSUES_FILE"
|
|
||||||
echo "$message" >> "$ISSUES_FILE"
|
|
||||||
echo "" >> "$ISSUES_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
add_fix() {
|
|
||||||
local issue="$1"
|
|
||||||
local fix="$2"
|
|
||||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
||||||
echo "" >> "$FIXES_FILE"
|
|
||||||
echo "### Issue: $issue" >> "$FIXES_FILE"
|
|
||||||
echo "**Detected:** $timestamp" >> "$FIXES_FILE"
|
|
||||||
echo "" >> "$FIXES_FILE"
|
|
||||||
echo "**Proposed Fix:**" >> "$FIXES_FILE"
|
|
||||||
echo "$fix" >> "$FIXES_FILE"
|
|
||||||
echo "" >> "$FIXES_FILE"
|
|
||||||
echo "---" >> "$FIXES_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# State tracking
|
|
||||||
PREV_STATE=""
|
|
||||||
STATE_STUCK_COUNT=0
|
|
||||||
PREV_CYCLE=0
|
|
||||||
LAST_ACTIVITY=0
|
|
||||||
AI_CHECK_ERRORS=0
|
|
||||||
|
|
||||||
echo "Starting respawn monitoring for session $SESSION_ID"
|
|
||||||
echo "Duration: $DURATION_HOURS hours"
|
|
||||||
echo "Issues logged to: $ISSUES_FILE"
|
|
||||||
echo "Fixes logged to: $FIXES_FILE"
|
|
||||||
|
|
||||||
while [ $(date +%s) -lt $END_TIME ]; do
|
|
||||||
# Get respawn status
|
|
||||||
RESP=$(curl -sk "$API_URL/api/sessions/$SESSION_ID" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -z "$RESP" ]; then
|
|
||||||
log_issue "ERROR" "API not responding - server may be down"
|
|
||||||
add_fix "API not responding" "Check if codeman-web service is running:\n\`\`\`bash\nsystemctl --user status codeman-web\nsystemctl --user restart codeman-web\n\`\`\`"
|
|
||||||
sleep $POLL_INTERVAL
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Parse response
|
|
||||||
STATE=$(echo "$RESP" | jq -r '.respawn.state // "null"')
|
|
||||||
CYCLE=$(echo "$RESP" | jq -r '.respawn.cycleCount // 0')
|
|
||||||
ENABLED=$(echo "$RESP" | jq -r '.respawnEnabled // false')
|
|
||||||
TOKENS=$(echo "$RESP" | jq -r '.respawn.config.lastTokenCount // 0')
|
|
||||||
AI_STATUS=$(echo "$RESP" | jq -r '.respawn.detection.aiCheck.status // "unknown"')
|
|
||||||
AI_ERRORS=$(echo "$RESP" | jq -r '.respawn.detection.aiCheck.consecutiveErrors // 0')
|
|
||||||
TIME_SINCE_ACTIVITY=$(echo "$RESP" | jq -r '.respawn.timeSinceActivity // 0')
|
|
||||||
CONFIDENCE=$(echo "$RESP" | jq -r '.respawn.detection.confidenceLevel // 0')
|
|
||||||
SESSION_STATUS=$(echo "$RESP" | jq -r '.status // "unknown"')
|
|
||||||
|
|
||||||
# Check for issues
|
|
||||||
|
|
||||||
# 1. Session not running
|
|
||||||
if [ "$SESSION_STATUS" = "stopped" ] || [ "$SESSION_STATUS" = "error" ]; then
|
|
||||||
log_issue "CRITICAL" "Session status is '$SESSION_STATUS' - respawn may not function correctly"
|
|
||||||
add_fix "Session stopped/error" "Check session health and restart if needed:\n\`\`\`bash\ncurl -sk $API_URL/api/sessions/$SESSION_ID\n# If needed, create new session or restart\n\`\`\`"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Respawn disabled unexpectedly
|
|
||||||
if [ "$ENABLED" = "false" ] && [ "$PREV_STATE" != "" ] && [ "$PREV_STATE" != "null" ]; then
|
|
||||||
log_issue "WARNING" "Respawn appears to have been disabled (was in state: $PREV_STATE)"
|
|
||||||
add_fix "Respawn disabled" "Re-enable respawn controller:\n\`\`\`bash\ncurl -sk -X POST $API_URL/api/sessions/$SESSION_ID/respawn/enable\n\`\`\`"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. State stuck for too long (>30 minutes in same state)
|
|
||||||
if [ "$STATE" = "$PREV_STATE" ] && [ "$STATE" != "watching" ]; then
|
|
||||||
STATE_STUCK_COUNT=$((STATE_STUCK_COUNT + 1))
|
|
||||||
if [ $STATE_STUCK_COUNT -ge 30 ]; then # 30 minutes
|
|
||||||
log_issue "WARNING" "State stuck in '$STATE' for $STATE_STUCK_COUNT minutes. Detection: confidence=$CONFIDENCE, timeSinceActivity=${TIME_SINCE_ACTIVITY}ms"
|
|
||||||
add_fix "State stuck in $STATE" "The respawn controller may be stuck. Options:\n1. Check terminal for blocking prompts\n2. Send input to unstick:\n\`\`\`bash\ncurl -sk -X POST $API_URL/api/sessions/$SESSION_ID/input -d '{\"input\": \"\\\\r\"}'\n\`\`\`\n3. Restart respawn:\n\`\`\`bash\ncurl -sk -X POST $API_URL/api/sessions/$SESSION_ID/respawn/stop\ncurl -sk -X POST $API_URL/api/sessions/$SESSION_ID/respawn/start\n\`\`\`"
|
|
||||||
STATE_STUCK_COUNT=0 # Reset to avoid spam
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
STATE_STUCK_COUNT=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. AI check errors
|
|
||||||
if [ "$AI_ERRORS" -gt 2 ] && [ "$AI_ERRORS" -gt "$AI_CHECK_ERRORS" ]; then
|
|
||||||
log_issue "WARNING" "AI idle check has $AI_ERRORS consecutive errors (status: $AI_STATUS)"
|
|
||||||
add_fix "AI check errors" "AI idle checker is failing. Check:\n1. Claude CLI is working\n2. Sufficient context available\n3. Consider disabling AI check temporarily:\n\`\`\`bash\ncurl -sk -X POST $API_URL/api/sessions/$SESSION_ID/respawn/config -H 'Content-Type: application/json' -d '{\"aiIdleCheckEnabled\": false}'\n\`\`\`"
|
|
||||||
AI_CHECK_ERRORS=$AI_ERRORS
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 5. Cycle completed - log success
|
|
||||||
if [ "$CYCLE" -gt "$PREV_CYCLE" ] && [ "$PREV_CYCLE" -gt 0 ]; then
|
|
||||||
log_issue "INFO" "Respawn cycle $CYCLE completed successfully (tokens: $TOKENS)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 6. Very high time since activity (>10 minutes) while not in watching state
|
|
||||||
if [ "$TIME_SINCE_ACTIVITY" -gt 600000 ] && [ "$STATE" != "watching" ]; then
|
|
||||||
log_issue "WARNING" "No activity for ${TIME_SINCE_ACTIVITY}ms while in state '$STATE' - may be stuck"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update tracking vars
|
|
||||||
PREV_STATE="$STATE"
|
|
||||||
PREV_CYCLE="$CYCLE"
|
|
||||||
|
|
||||||
sleep $POLL_INTERVAL
|
|
||||||
done
|
|
||||||
|
|
||||||
log_issue "INFO" "Monitoring completed after $DURATION_HOURS hours"
|
|
||||||
echo "Monitoring complete. Check $ISSUES_FILE and $FIXES_FILE"
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Ralph Loop Wizard Demo - runs through browser, leaves session running
|
|
||||||
*/
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import { readFileSync, existsSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
const BASE_URL = 'https://localhost:3000';
|
|
||||||
const CASE_NAME = 'codeman-ios';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('🎬 Starting Ralph Loop Wizard demo...\n');
|
|
||||||
|
|
||||||
// Launch browser (visible)
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: false,
|
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
|
|
||||||
slowMo: 500, // Slow down so you can see what's happening
|
|
||||||
});
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
ignoreHTTPSErrors: true,
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
console.log('📱 Opening Codeman web UI...');
|
|
||||||
await page.goto(BASE_URL);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
console.log('🔄 Clicking Ralph Loop button...');
|
|
||||||
await page.waitForSelector('.btn-ralph', { timeout: 10000 });
|
|
||||||
await page.click('.btn-ralph');
|
|
||||||
|
|
||||||
console.log('📋 Waiting for wizard modal...');
|
|
||||||
await page.waitForSelector('#ralphWizardModal.active', { timeout: 10000 });
|
|
||||||
|
|
||||||
// Select case
|
|
||||||
console.log(`📁 Selecting case: ${CASE_NAME}`);
|
|
||||||
await page.selectOption('#ralphCaseSelect', CASE_NAME);
|
|
||||||
|
|
||||||
// Load initprompt.md if it exists
|
|
||||||
const initPromptPath = join(homedir(), 'codeman-cases', CASE_NAME, 'initprompt.md');
|
|
||||||
let taskDescription = 'Build an awesome iOS app for Codeman!';
|
|
||||||
if (existsSync(initPromptPath)) {
|
|
||||||
taskDescription = readFileSync(initPromptPath, 'utf-8').trim();
|
|
||||||
console.log(`📝 Loaded initprompt.md (${taskDescription.length} chars)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter task description
|
|
||||||
await page.fill('#ralphTaskDescription', taskDescription);
|
|
||||||
|
|
||||||
// Set completion phrase
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const input = document.getElementById('ralphCompletionPhrase');
|
|
||||||
if (input) input.value = 'IOS_APP_COMPLETE';
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('➡️ Clicking Next (Step 1 → Step 2)...');
|
|
||||||
await page.click('#ralphNextBtn');
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Check for existing plan
|
|
||||||
const existingPlan = await page.$('#existingPlanSection:not(.hidden)');
|
|
||||||
if (existingPlan) {
|
|
||||||
console.log('📋 Existing plan detected, using it...');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const btns = Array.from(document.querySelectorAll('button'));
|
|
||||||
const useBtn = btns.find(b => b.textContent?.includes('Use Existing'));
|
|
||||||
if (useBtn) useBtn.click();
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('➡️ Clicking Next (Step 2 → Step 3)...');
|
|
||||||
await page.click('#ralphNextBtn');
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
console.log('🚀 Clicking Start Loop...');
|
|
||||||
await page.click('#ralphStartBtn');
|
|
||||||
|
|
||||||
// Wait for session to be created
|
|
||||||
console.log('⏳ Waiting for session to start...');
|
|
||||||
await page.waitForSelector('.session-tab', { timeout: 30000 });
|
|
||||||
|
|
||||||
console.log('\n✅ Ralph Loop started! Session is running.');
|
|
||||||
console.log('👀 Browser will stay open so you can observe.');
|
|
||||||
console.log(' Press Ctrl+C in terminal when done.\n');
|
|
||||||
|
|
||||||
// Keep the script running
|
|
||||||
await new Promise(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error('Error:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Start Ralph Loop on PRODUCTION server (port 3000) using E2E fixtures
|
|
||||||
* Uses initprompt.md and @fix_plan.md from the case directory
|
|
||||||
* Does NOT clean up - leaves session running for user verification
|
|
||||||
*/
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import { readFileSync, existsSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
const BASE_URL = 'https://localhost:3000';
|
|
||||||
const CASE_NAME = 'codeman-ios';
|
|
||||||
const CASE_PATH = join(homedir(), 'codeman-cases', CASE_NAME);
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// Read initprompt.md
|
|
||||||
const initPromptPath = join(CASE_PATH, 'initprompt.md');
|
|
||||||
const fixPlanPath = join(CASE_PATH, '@fix_plan.md');
|
|
||||||
|
|
||||||
if (!existsSync(initPromptPath)) {
|
|
||||||
console.error(`Missing: ${initPromptPath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskDescription = readFileSync(initPromptPath, 'utf-8').trim();
|
|
||||||
const hasFixPlan = existsSync(fixPlanPath);
|
|
||||||
|
|
||||||
console.log('=== Ralph Loop Wizard E2E (Production) ===');
|
|
||||||
console.log(`Case: ${CASE_NAME}`);
|
|
||||||
console.log(`initprompt.md: ${taskDescription.length} chars`);
|
|
||||||
console.log(`@fix_plan.md exists: ${hasFixPlan}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
// Capture browser console logs
|
|
||||||
page.on('console', msg => {
|
|
||||||
if (msg.type() === 'error' || msg.text().includes('RalphWizard')) {
|
|
||||||
console.log(`[Browser ${msg.type()}] ${msg.text()}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
page.on('pageerror', err => console.log(`[Browser Error] ${err.message}`));
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('[1/10] Navigating to Codeman...');
|
|
||||||
await page.goto(BASE_URL, { timeout: 10000 });
|
|
||||||
|
|
||||||
console.log('[2/10] Clicking Ralph Loop button (.btn-ralph)...');
|
|
||||||
await page.waitForSelector('.btn-ralph', { timeout: 5000 });
|
|
||||||
await page.click('.btn-ralph');
|
|
||||||
|
|
||||||
console.log('[3/10] Waiting for wizard modal (#ralphWizardModal.active)...');
|
|
||||||
await page.waitForSelector('#ralphWizardModal.active', { timeout: 5000 });
|
|
||||||
|
|
||||||
console.log(`[4/10] Selecting case: ${CASE_NAME}...`);
|
|
||||||
await page.selectOption('#ralphCaseSelect', CASE_NAME);
|
|
||||||
|
|
||||||
console.log('[5/10] Filling task description from initprompt.md...');
|
|
||||||
await page.fill('#ralphTaskDescription', taskDescription);
|
|
||||||
|
|
||||||
console.log('[6/10] Setting completion phrase: IOS_APP_COMPLETE...');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const input = document.getElementById('ralphCompletionPhrase');
|
|
||||||
if (input) input.value = 'IOS_APP_COMPLETE';
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[7/10] Clicking Next -> Step 2...');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.getElementById('ralphNextBtn')?.click();
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Check for existing @fix_plan.md detection
|
|
||||||
const existingPlanSection = await page.$('#existingPlanSection:not(.hidden)');
|
|
||||||
if (existingPlanSection) {
|
|
||||||
console.log('[8/10] Existing @fix_plan.md detected, clicking "Use Existing"...');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const btns = Array.from(document.querySelectorAll('button'));
|
|
||||||
const useBtn = btns.find(b => b.textContent?.includes('Use Existing'));
|
|
||||||
if (useBtn) useBtn.click();
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
} else {
|
|
||||||
console.log('[8/10] No existing plan section visible...');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[9/10] Clicking Next -> Step 3 (Launch)...');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.getElementById('ralphNextBtn')?.click();
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
console.log('[10/10] Clicking Start Loop...');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.getElementById('ralphStartBtn')?.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Waiting for session tab to appear...');
|
|
||||||
await page.waitForSelector('.session-tab', { timeout: 30000 });
|
|
||||||
|
|
||||||
// IMPORTANT: Wait for the wizard's async startRalphLoop() to complete
|
|
||||||
// The wizard needs time to: wait for session ready, then send the prompt
|
|
||||||
console.log('Waiting for wizard to send prompt (checking for busy status)...');
|
|
||||||
|
|
||||||
let session = null;
|
|
||||||
let attempts = 0;
|
|
||||||
const maxAttempts = 30; // 30 seconds max
|
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
const sessions = await page.evaluate(async () => {
|
|
||||||
const res = await fetch('/api/sessions');
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
|
|
||||||
session = sessions
|
|
||||||
.filter(s => s.workingDir?.includes('codeman-ios'))
|
|
||||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))[0];
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
// Check if session is working (prompt was sent)
|
|
||||||
const detail = await page.evaluate(async (id) => {
|
|
||||||
const res = await fetch(`/api/sessions/${id}`);
|
|
||||||
return res.json();
|
|
||||||
}, session.id);
|
|
||||||
|
|
||||||
if (detail.isWorking || detail.tokens?.total > 0) {
|
|
||||||
console.log(`Session is working! Tokens: ${detail.tokens?.total}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
console.log(` Attempt ${attempts + 1}: status=${detail.status}, tokens=${detail.tokens?.total || 0}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session || attempts >= maxAttempts) {
|
|
||||||
console.error('WARNING: Session may not have received the prompt!');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log('========================================');
|
|
||||||
console.log('SUCCESS! Ralph Loop started.');
|
|
||||||
console.log('========================================');
|
|
||||||
console.log(`Session ID: ${session?.id}`);
|
|
||||||
console.log(`Screen: codeman-${session?.id?.slice(0, 8)}`);
|
|
||||||
console.log(`Status: ${session?.status}`);
|
|
||||||
console.log('');
|
|
||||||
console.log('Session is now running. View at: https://localhost:3000');
|
|
||||||
console.log('');
|
|
||||||
console.log('To delete when done:');
|
|
||||||
console.log(` curl -sk -X DELETE "https://localhost:3000/api/sessions/${session?.id}"`);
|
|
||||||
console.log('========================================');
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error('FAILED:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Start Ralph Loop via wizard on production server, leave it running
|
|
||||||
*/
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import { readFileSync, existsSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
const BASE_URL = 'https://localhost:3000';
|
|
||||||
const CASE_NAME = 'codeman-ios';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('Starting Ralph Loop via wizard on production server...\n');
|
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
console.log('1. Opening Codeman web UI...');
|
|
||||||
await page.goto(BASE_URL);
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
console.log('2. Clicking Ralph Loop button...');
|
|
||||||
await page.waitForSelector('.btn-ralph', { timeout: 10000 });
|
|
||||||
await page.click('.btn-ralph');
|
|
||||||
|
|
||||||
console.log('3. Waiting for wizard modal...');
|
|
||||||
await page.waitForSelector('#ralphWizardModal.active', { timeout: 10000 });
|
|
||||||
|
|
||||||
console.log(`4. Selecting case: ${CASE_NAME}`);
|
|
||||||
await page.selectOption('#ralphCaseSelect', CASE_NAME);
|
|
||||||
|
|
||||||
// Load initprompt.md
|
|
||||||
const initPromptPath = join(homedir(), 'codeman-cases', CASE_NAME, 'initprompt.md');
|
|
||||||
let taskDescription = 'Build an awesome iOS app for Codeman!';
|
|
||||||
if (existsSync(initPromptPath)) {
|
|
||||||
taskDescription = readFileSync(initPromptPath, 'utf-8').trim();
|
|
||||||
console.log(`5. Loaded initprompt.md (${taskDescription.length} chars)`);
|
|
||||||
} else {
|
|
||||||
console.log('5. Using default task description');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.fill('#ralphTaskDescription', taskDescription);
|
|
||||||
|
|
||||||
// Set completion phrase
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const input = document.getElementById('ralphCompletionPhrase');
|
|
||||||
if (input) input.value = 'IOS_APP_COMPLETE';
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('6. Clicking Next (Step 1 -> Step 2)...');
|
|
||||||
await page.click('#ralphNextBtn');
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Check for existing plan
|
|
||||||
const existingPlan = await page.$('#existingPlanSection:not(.hidden)');
|
|
||||||
if (existingPlan) {
|
|
||||||
console.log('7. Existing plan detected, using it...');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const btns = Array.from(document.querySelectorAll('button'));
|
|
||||||
const useBtn = btns.find(b => b.textContent?.includes('Use Existing'));
|
|
||||||
if (useBtn) useBtn.click();
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
} else {
|
|
||||||
console.log('7. No existing plan, proceeding...');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('8. Clicking Next (Step 2 -> Step 3)...');
|
|
||||||
await page.click('#ralphNextBtn');
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
console.log('9. Clicking Start Loop...');
|
|
||||||
await page.click('#ralphStartBtn');
|
|
||||||
|
|
||||||
console.log('10. Waiting for session to be created...');
|
|
||||||
await page.waitForSelector('.session-tab', { timeout: 30000 });
|
|
||||||
|
|
||||||
// Get session info from the API
|
|
||||||
const sessions = await page.evaluate(async () => {
|
|
||||||
const res = await fetch('/api/sessions');
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
|
|
||||||
const iosSession = sessions.find(s => s.workingDir?.includes('codeman-ios'));
|
|
||||||
|
|
||||||
console.log('\n========================================');
|
|
||||||
console.log('SUCCESS! Ralph Loop started.');
|
|
||||||
console.log('========================================');
|
|
||||||
console.log(`Session ID: ${iosSession?.id}`);
|
|
||||||
console.log(`Screen: codeman-${iosSession?.id?.slice(0, 8)}`);
|
|
||||||
console.log(`Working Dir: ${iosSession?.workingDir}`);
|
|
||||||
console.log('\nThe session is now running on your production server.');
|
|
||||||
console.log('View it at: https://localhost:3000');
|
|
||||||
console.log('Kill it when done via the web UI or: curl -X DELETE https://localhost:3000/api/sessions/' + iosSession?.id);
|
|
||||||
console.log('========================================\n');
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error('Error:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Browser test for clickable file links in Codeman terminal
|
|
||||||
* Uses Playwright directly to ignore HTTPS errors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
|
|
||||||
const BASE_URL = 'https://localhost:3000';
|
|
||||||
const TEST_LOG_FILE = '/tmp/test-link-click.log';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('Starting browser test for clickable links...\n');
|
|
||||||
|
|
||||||
// Create test log file
|
|
||||||
const { writeFileSync, appendFileSync } = await import('fs');
|
|
||||||
writeFileSync(TEST_LOG_FILE, `=== Test started at ${new Date().toISOString()} ===\n`);
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
|
||||||
appendFileSync(TEST_LOG_FILE, `Entry #${i} - ${Date.now()}\n`);
|
|
||||||
}
|
|
||||||
console.log(`Created test file: ${TEST_LOG_FILE}\n`);
|
|
||||||
|
|
||||||
// Launch browser ignoring HTTPS errors
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
args: ['--ignore-certificate-errors', '--ignore-ssl-errors']
|
|
||||||
});
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
ignoreHTTPSErrors: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Navigate to Codeman
|
|
||||||
console.log('Navigating to Codeman...');
|
|
||||||
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 30000 });
|
|
||||||
console.log('Page loaded: ' + await page.title());
|
|
||||||
|
|
||||||
// Wait for terminal to be ready
|
|
||||||
await page.waitForSelector('.xterm-screen', { timeout: 10000 });
|
|
||||||
console.log('Terminal found\n');
|
|
||||||
|
|
||||||
// Get list of sessions
|
|
||||||
const sessions = await page.evaluate(async () => {
|
|
||||||
const res = await fetch('/api/sessions');
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
console.log(`Found ${sessions.length} session(s)`);
|
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
|
||||||
console.log('No sessions found. Creating a shell session...');
|
|
||||||
const newSession = await page.evaluate(async () => {
|
|
||||||
const res = await fetch('/api/quick-start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ caseName: 'link-test', mode: 'shell' })
|
|
||||||
});
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
console.log('Created session:', newSession.session?.id);
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get active session (use codeman session if exists)
|
|
||||||
const codemanSession = sessions.find(s => s.workingDir?.includes('codeman'));
|
|
||||||
const sessionId = codemanSession?.id || sessions[0]?.id;
|
|
||||||
console.log(`Using session: ${sessionId}\n`);
|
|
||||||
|
|
||||||
// Send a command that outputs file paths
|
|
||||||
console.log('Sending command with file path...');
|
|
||||||
await page.evaluate(async (sid, logFile) => {
|
|
||||||
await fetch(`/api/sessions/${sid}/input`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ input: `echo "Monitor: tail -f ${logFile}"\r` })
|
|
||||||
});
|
|
||||||
}, sessionId, TEST_LOG_FILE);
|
|
||||||
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Take screenshot
|
|
||||||
await page.screenshot({ path: '/tmp/codeman-link-test-1.png' });
|
|
||||||
console.log('Screenshot saved: /tmp/codeman-link-test-1.png\n');
|
|
||||||
|
|
||||||
// Check if terminal contains our file path
|
|
||||||
const terminalContent = await page.evaluate(() => {
|
|
||||||
const screen = document.querySelector('.xterm-screen');
|
|
||||||
return screen?.textContent || '';
|
|
||||||
});
|
|
||||||
|
|
||||||
if (terminalContent.includes(TEST_LOG_FILE)) {
|
|
||||||
console.log('File path appears in terminal output');
|
|
||||||
} else {
|
|
||||||
console.log('WARNING: File path not found in terminal. Content preview:');
|
|
||||||
console.log(terminalContent.substring(0, 500));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find clickable links in xterm
|
|
||||||
const linkCount = await page.evaluate(() => {
|
|
||||||
// xterm link detection happens on mouse move, so we need to trigger it
|
|
||||||
const links = document.querySelectorAll('.xterm-link');
|
|
||||||
return links.length;
|
|
||||||
});
|
|
||||||
console.log(`\nClickable links found: ${linkCount}`);
|
|
||||||
|
|
||||||
// Try hovering over the terminal to trigger link detection
|
|
||||||
console.log('\nHovering over terminal to trigger link detection...');
|
|
||||||
const termRect = await page.locator('.xterm-screen').boundingBox();
|
|
||||||
if (termRect) {
|
|
||||||
// Move mouse across the terminal
|
|
||||||
for (let y = termRect.y + 50; y < termRect.y + termRect.height - 50; y += 20) {
|
|
||||||
for (let x = termRect.x + 50; x < termRect.x + termRect.width - 50; x += 50) {
|
|
||||||
await page.mouse.move(x, y);
|
|
||||||
await page.waitForTimeout(50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for tooltip (shows when hovering over link)
|
|
||||||
const tooltipVisible = await page.evaluate(() => {
|
|
||||||
const tooltip = document.getElementById('file-link-tooltip');
|
|
||||||
return tooltip?.style.display !== 'none' && tooltip?.style.display !== '';
|
|
||||||
});
|
|
||||||
console.log('Tooltip visible:', tooltipVisible);
|
|
||||||
|
|
||||||
// Check for log viewer windows
|
|
||||||
const logViewerCount = await page.evaluate(() => {
|
|
||||||
return document.querySelectorAll('.log-viewer-window').length;
|
|
||||||
});
|
|
||||||
console.log('Log viewer windows:', logViewerCount);
|
|
||||||
|
|
||||||
// Take final screenshot
|
|
||||||
await page.screenshot({ path: '/tmp/codeman-link-test-2.png' });
|
|
||||||
console.log('\nFinal screenshot: /tmp/codeman-link-test-2.png');
|
|
||||||
|
|
||||||
// Test the link provider registration
|
|
||||||
console.log('\n--- Link Provider Debug ---');
|
|
||||||
const debugInfo = await page.evaluate(() => {
|
|
||||||
// Check if app has link provider registered
|
|
||||||
const app = window.app;
|
|
||||||
if (!app) return { error: 'No app object found' };
|
|
||||||
|
|
||||||
const terminal = app.terminals?.get(app.activeSessionId);
|
|
||||||
if (!terminal) return { error: 'No active terminal', activeSessionId: app.activeSessionId };
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasTerminal: true,
|
|
||||||
activeSessionId: app.activeSessionId,
|
|
||||||
terminalBuffer: terminal.buffer?.active?.length || 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
console.log('Debug info:', debugInfo);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error.message);
|
|
||||||
await page.screenshot({ path: '/tmp/codeman-link-test-error.png' });
|
|
||||||
console.log('Error screenshot saved to /tmp/codeman-link-test-error.png');
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
console.log('\nBrowser closed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -72,7 +72,7 @@ import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
|||||||
import { tmpdir, homedir } from 'node:os';
|
import { tmpdir, homedir } from 'node:os';
|
||||||
import { join, delimiter, dirname } from 'node:path';
|
import { join, delimiter, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { enabledClis } from '../src/config/cli-registry/index.js';
|
import { enabledClis } from '../src/config/cli-registry/registry.js';
|
||||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||||
import {
|
import {
|
||||||
buildCustomModelInjection,
|
buildCustomModelInjection,
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Test script for link detection patterns
|
|
||||||
*/
|
|
||||||
|
|
||||||
const patterns = {
|
|
||||||
cmdPattern: /(tail|cat|head|less|grep|watch|multitail)\s+(?:[^\/\n]+?\s+)?(\/[^\s"'<>|;&\n\)]+)/g,
|
|
||||||
bashPathPattern: /Bash\([^)]*?(\/(?:var|tmp|home|usr|etc|opt|log|logs)[^\s"'<>|;&\)\n]+)/g,
|
|
||||||
generalPathPattern: /(\/(?:home|tmp|var|etc|usr|opt)[^\s"'<>|;&\n\)\]]+\.(?:log|txt|json|md|ts|js|sh|py|yaml|yml|csv|xml|html|css))/g,
|
|
||||||
logPathPattern: /(\/var\/log\/[^\s"'<>|;&\n\)\]]+)/g,
|
|
||||||
claudeOutputPattern: /(?:Created|Writing|Saved|File|Output|Streaming|Monitoring|Log)(?:\s+(?:file|to|at))?[:\s]+([\/~][^\s"'<>|;&\n\)]+)/gi
|
|
||||||
};
|
|
||||||
|
|
||||||
const tests = [
|
|
||||||
'tail -f /tmp/test.log',
|
|
||||||
'tail -n 100 /var/log/syslog',
|
|
||||||
'cat /home/user/file.txt',
|
|
||||||
'head -n 10 /etc/passwd',
|
|
||||||
'Bash(tail -f /tmp/test.log)',
|
|
||||||
'Created file: /tmp/newfile.log',
|
|
||||||
'Writing to /home/user/output.txt',
|
|
||||||
'Monitoring file: /var/log/syslog',
|
|
||||||
'Streaming /home/user/logs/app.log',
|
|
||||||
'/home/arkon/project/file.log',
|
|
||||||
'/tmp/test-output.txt',
|
|
||||||
'echo "tail -f /tmp/test.log"',
|
|
||||||
'The output is in /home/user/results.csv',
|
|
||||||
'plain text without paths'
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log('Link Pattern Test Results\n');
|
|
||||||
|
|
||||||
for (const [name, pattern] of Object.entries(patterns)) {
|
|
||||||
console.log(`\n=== ${name} ===`);
|
|
||||||
console.log(`Pattern: ${pattern.source}\n`);
|
|
||||||
|
|
||||||
for (const test of tests) {
|
|
||||||
pattern.lastIndex = 0;
|
|
||||||
const matches = [];
|
|
||||||
let m;
|
|
||||||
while ((m = pattern.exec(test)) !== null) {
|
|
||||||
// Get the captured group (varies by pattern)
|
|
||||||
matches.push(m[1] || m[2]);
|
|
||||||
}
|
|
||||||
if (matches.length > 0) {
|
|
||||||
console.log(` ✓ "${test}"`);
|
|
||||||
console.log(` → Found: ${matches.join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n\n=== Combined Test ===');
|
|
||||||
console.log('Testing all patterns on each input:\n');
|
|
||||||
|
|
||||||
for (const test of tests) {
|
|
||||||
const allMatches = [];
|
|
||||||
for (const [name, pattern] of Object.entries(patterns)) {
|
|
||||||
pattern.lastIndex = 0;
|
|
||||||
let m;
|
|
||||||
while ((m = pattern.exec(test)) !== null) {
|
|
||||||
const path = m[1] || m[2];
|
|
||||||
if (path && !allMatches.includes(path)) {
|
|
||||||
allMatches.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (allMatches.length > 0) {
|
|
||||||
console.log(`✓ "${test}"`);
|
|
||||||
console.log(` → ${allMatches.join(', ')}`);
|
|
||||||
} else {
|
|
||||||
console.log(`✗ "${test}" - no match`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Test script for clickable file links in Codeman terminal
|
|
||||||
# Creates a log file and continuously writes timestamped data
|
|
||||||
|
|
||||||
LOGFILE="/tmp/codeman-test-$(date +%s).log"
|
|
||||||
|
|
||||||
echo "Created log file: $LOGFILE"
|
|
||||||
echo ""
|
|
||||||
echo "You can monitor this file with:"
|
|
||||||
echo " tail -f $LOGFILE"
|
|
||||||
echo ""
|
|
||||||
echo "Starting to write data every 2 seconds..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Write initial content
|
|
||||||
echo "=== Test Log Started at $(date) ===" > "$LOGFILE"
|
|
||||||
|
|
||||||
# Write data continuously
|
|
||||||
count=0
|
|
||||||
while true; do
|
|
||||||
count=$((count + 1))
|
|
||||||
echo "[$(date '+%H:%M:%S')] Entry #$count - Random: $RANDOM" >> "$LOGFILE"
|
|
||||||
|
|
||||||
# Print status every 5 entries
|
|
||||||
if [ $((count % 5)) -eq 0 ]; then
|
|
||||||
echo "Wrote $count entries to $LOGFILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
/**
|
|
||||||
* Manual verification harness for the session-sidebar feature.
|
|
||||||
*
|
|
||||||
* Renders the real UI in headless Chromium against a testMode WebServer,
|
|
||||||
* injects a synthetic 25-session fleet, and screenshots every layout state.
|
|
||||||
* Not part of the automated suite — run it by hand:
|
|
||||||
*
|
|
||||||
* npx tsx scripts/verify-session-sidebar.mts
|
|
||||||
*
|
|
||||||
* SAFETY: uses the repo's own test harness (temp HOME, testMode server) on a
|
|
||||||
* dedicated port. It never touches a real Codeman instance or tmux socket.
|
|
||||||
*/
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import { WebServer } from '../src/web/server.js';
|
|
||||||
import { mkdirSync } from 'node:fs';
|
|
||||||
import { mkdtempSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
// Mirror test/setup.ts: isolate HOME before the app modules touch state.
|
|
||||||
process.env.HOME = mkdtempSync(join(tmpdir(), 'codeman-sidebar-verify-'));
|
|
||||||
process.env.VITEST = 'true';
|
|
||||||
|
|
||||||
const PORT = 3299;
|
|
||||||
const OUT = process.env.SIDEBAR_SHOTS_DIR ?? join(tmpdir(), 'codeman-sidebar-shots');
|
|
||||||
mkdirSync(OUT, { recursive: true });
|
|
||||||
|
|
||||||
// Generic on purpose: these names end up in the harness screenshots, so they
|
|
||||||
// should not carry one contributor's project list into everyone else's review.
|
|
||||||
// The mix of CLI modes matters (each renders a different badge); the names do not.
|
|
||||||
const PROJECTS = [
|
|
||||||
['api-server', 'claude'],
|
|
||||||
['web-client', 'claude'],
|
|
||||||
['mobile-app', 'codex'],
|
|
||||||
['data-pipeline', 'claude'],
|
|
||||||
['shared-lib', 'gemini'],
|
|
||||||
['codeman', 'claude'],
|
|
||||||
['docs-site', 'claude'],
|
|
||||||
['batch-jobs', 'opencode'],
|
|
||||||
['search-index', 'claude'],
|
|
||||||
];
|
|
||||||
const STATUSES = ['idle', 'busy', 'idle', 'busy', 'error', 'idle'];
|
|
||||||
|
|
||||||
function fleet(n: number) {
|
|
||||||
const out: any[] = [];
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const [proj, mode] = PROJECTS[i % PROJECTS.length];
|
|
||||||
const status = STATUSES[i % STATUSES.length];
|
|
||||||
out.push({
|
|
||||||
id: `sess-${String(i).padStart(4, '0')}-aaaa-bbbb-cccc-dddddddddddd`,
|
|
||||||
pid: 10000 + i,
|
|
||||||
status,
|
|
||||||
workingDir: `${tmpdir()}/projects/${proj}`,
|
|
||||||
name: `${proj}${i > 8 ? '-' + Math.floor(i / 9) : ''}`,
|
|
||||||
mode,
|
|
||||||
currentTaskId: null,
|
|
||||||
createdAt: Date.now() - i * 60000,
|
|
||||||
lastActivityAt: Date.now() - i * 1000,
|
|
||||||
isWorking: status === 'busy',
|
|
||||||
messageCount: i * 3,
|
|
||||||
totalCost: 0,
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
color: 'default',
|
|
||||||
taskStats: { total: i % 4, running: i % 3 === 0 ? 2 : 0, completed: 0, failed: 0 },
|
|
||||||
taskTree: [],
|
|
||||||
tokens: { input: 0, output: 0, total: 0 },
|
|
||||||
bufferStats: { terminalBufferSize: 0, textOutputSize: 0, messageCount: 0 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSIONS = fleet(25);
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const server = new WebServer(PORT, false, true);
|
|
||||||
await server.start();
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
async function shot(
|
|
||||||
name: string,
|
|
||||||
opts: { layout: 'header' | 'sidebar'; collapsed?: boolean; width: number; height: number; touch?: boolean }
|
|
||||||
) {
|
|
||||||
const ctx = await browser.newContext({
|
|
||||||
viewport: { width: opts.width, height: opts.height },
|
|
||||||
hasTouch: !!opts.touch,
|
|
||||||
isMobile: !!opts.touch,
|
|
||||||
deviceScaleFactor: 2,
|
|
||||||
});
|
|
||||||
const page = await ctx.newPage();
|
|
||||||
const settings = JSON.stringify({ sessionListLayout: opts.layout });
|
|
||||||
const collapsed = opts.collapsed === undefined ? null : opts.collapsed ? '1' : '0';
|
|
||||||
await page.addInitScript(
|
|
||||||
([s, c]) => {
|
|
||||||
localStorage.setItem('codeman-app-settings', s as string);
|
|
||||||
localStorage.setItem('codeman-app-settings-mobile', s as string);
|
|
||||||
if (c !== null) localStorage.setItem('codeman-sidebar-collapsed', c as string);
|
|
||||||
else localStorage.removeItem('codeman-sidebar-collapsed');
|
|
||||||
},
|
|
||||||
[settings, collapsed]
|
|
||||||
);
|
|
||||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
await page.evaluate((list) => {
|
|
||||||
const app = (window as any).app;
|
|
||||||
if (!app) throw new Error('no window.app');
|
|
||||||
app.sessions.clear();
|
|
||||||
for (const s of list as any[]) app.sessions.set(s.id, s);
|
|
||||||
// The renderer iterates sessionOrder, not the map.
|
|
||||||
app.sessionOrder = (list as any[]).map((s) => s.id);
|
|
||||||
app.activeSessionId = (list as any[])[3].id;
|
|
||||||
// renderSessionTabs() is debounced; drive the immediate path directly.
|
|
||||||
(app._fullRenderSessionTabs ?? app._renderSessionTabsImmediate)?.call(app);
|
|
||||||
app.applySessionListLayout?.();
|
|
||||||
}, SESSIONS as any);
|
|
||||||
await page.waitForTimeout(600);
|
|
||||||
|
|
||||||
const info = await page.evaluate(() => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
const aside = document.getElementById('sessionSidebar');
|
|
||||||
const tabsEl = document.getElementById('sessionTabs');
|
|
||||||
const asideBox = aside?.getBoundingClientRect();
|
|
||||||
const cs = aside ? getComputedStyle(aside) : null;
|
|
||||||
return {
|
|
||||||
dataSessionList: root.dataset.sessionList ?? null,
|
|
||||||
dataSidebar: root.dataset.sidebar ?? null,
|
|
||||||
rows: document.querySelectorAll('.session-tab').length,
|
|
||||||
tabsParent: tabsEl?.parentElement?.id || tabsEl?.parentElement?.className || null,
|
|
||||||
asideWidth: asideBox ? Math.round(asideBox.width) : null,
|
|
||||||
asideVisible: cs ? cs.display !== 'none' && cs.visibility !== 'hidden' : null,
|
|
||||||
asideInert: aside?.hasAttribute('inert') ?? null,
|
|
||||||
ariaHidden: aside?.getAttribute('aria-hidden') ?? null,
|
|
||||||
toggleAriaExpanded: document.getElementById('sidebarToggleBtn')?.getAttribute('aria-expanded') ?? null,
|
|
||||||
firstRowText:
|
|
||||||
(document.querySelector('.session-tab') as HTMLElement | null)?.innerText
|
|
||||||
?.trim()
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.slice(0, 40) ?? null,
|
|
||||||
listScrollable: (() => {
|
|
||||||
const el = document.getElementById('sessionTabs');
|
|
||||||
return el ? el.scrollHeight > el.clientHeight + 2 : null;
|
|
||||||
})(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.waitForTimeout(400);
|
|
||||||
const file = join(OUT, `${name}.png`);
|
|
||||||
await page.screenshot({ path: file });
|
|
||||||
results.push(`${name.padEnd(28)} ${JSON.stringify(info)}`);
|
|
||||||
await ctx.close();
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
await shot('01-header-desktop', { layout: 'header', width: 1600, height: 900 });
|
|
||||||
await shot('02-sidebar-expanded', { layout: 'sidebar', collapsed: false, width: 1600, height: 900 });
|
|
||||||
await shot('03-sidebar-collapsed-rail', { layout: 'sidebar', collapsed: true, width: 1600, height: 900 });
|
|
||||||
await shot('04-sidebar-narrow-1000', { layout: 'sidebar', collapsed: true, width: 1000, height: 800 });
|
|
||||||
await shot('05-sidebar-drawer-open-1000', { layout: 'sidebar', collapsed: false, width: 1000, height: 800 });
|
|
||||||
await shot('06-sidebar-phone-closed', { layout: 'sidebar', collapsed: true, width: 393, height: 852, touch: true });
|
|
||||||
await shot('07-sidebar-phone-open', { layout: 'sidebar', collapsed: false, width: 393, height: 852, touch: true });
|
|
||||||
|
|
||||||
console.log('\n=== RESULTS ===');
|
|
||||||
for (const r of results) console.log(r);
|
|
||||||
console.log(`\nScreenshots in ${OUT}`);
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
await server.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().then(
|
|
||||||
() => process.exit(0),
|
|
||||||
(e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
+147
-42
@@ -107,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
@@ -135,8 +135,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
|||||||
mkdir -p "$(dirname "$PRE")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||||
grep -qs '^CODEMAN_PREAMBLE=1.50.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
grep -qs '^CODEMAN_PREAMBLE=1.51.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||||
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.51.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||||
# from that body (test/agent-skill.test.ts pins it).
|
# from that body (test/agent-skill.test.ts pins it).
|
||||||
@@ -197,6 +197,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
|
|||||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||||
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
||||||
}
|
}
|
||||||
|
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
|
||||||
|
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
|
||||||
|
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
|
||||||
|
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
|
||||||
|
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
|
||||||
|
# RENDERED pane with its whitespace removed, and never handed back while it shows.
|
||||||
|
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
|
||||||
|
| jq -r '.data.terminalBuffer // empty' \
|
||||||
|
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
|
||||||
|
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
|
||||||
|
}
|
||||||
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
||||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||||
# "No, exit" by default:
|
# "No, exit" by default:
|
||||||
@@ -259,9 +270,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
||||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||||
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||||
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
|
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
|
||||||
|
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
|
||||||
|
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
|
||||||
|
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
|
||||||
|
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
|
||||||
|
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
|
||||||
|
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
|
||||||
|
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
|
||||||
|
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
|
||||||
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
||||||
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
|
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
|
||||||
|
# that can run the model is on this Mac), blocked (installed, but the server
|
||||||
# refuses a session now: the model changed on disk, another program holds the port, the
|
# refuses a session now: the model changed on disk, another program holds the port, the
|
||||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||||
# local models, 404), unauthorized (401, §6), unreachable, error.
|
# local models, 404), unauthorized (401, §6), unreachable, error.
|
||||||
@@ -277,12 +297,23 @@ local_model() {
|
|||||||
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
||||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||||
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
||||||
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
|
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
|
||||||
|
agents:(if ($i.harnesses | type) == "array"
|
||||||
|
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
|
||||||
|
else null end),
|
||||||
|
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
|
||||||
|
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
|
||||||
|
else null end)} as $base
|
||||||
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
||||||
elif $i.state == "installed" then
|
elif $i.state == "installed" then
|
||||||
|
# With the agents listed and none of them startable there is nothing to spawn,
|
||||||
|
# running or not: say that, not "blocked".
|
||||||
|
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
|
||||||
|
then {spawnable:false, reason:"no-agent",
|
||||||
|
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
|
||||||
# `start` alone over-promises: it is offered too while another program holds the
|
# `start` alone over-promises: it is offered too while another program holds the
|
||||||
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
||||||
if ($i.cli and $i.endpointId and $i.model.id)
|
elif ($i.cli and $i.endpointId and $i.model.id)
|
||||||
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
||||||
then {spawnable:true} + $base
|
then {spawnable:true} + $base
|
||||||
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
||||||
@@ -345,31 +376,55 @@ _local_ready_model() {
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
|
||||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
|
||||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
|
||||||
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
|
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
|
||||||
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
|
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
|
||||||
# never confirms on anyone's behalf.
|
# that wants a person to confirm something (a context below the agent's safe floor, a model
|
||||||
|
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
|
||||||
|
# agent. All local workers share ONE model that answers one request at a time.
|
||||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
# committing to an approach, on a recurring error and before declaring the task done.
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
|
||||||
|
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
|
||||||
if [ "$mode" = local ]; then
|
if [ "$mode" = local ]; then
|
||||||
lm=$(_local_ready_model) || return 1
|
lm=$(_local_ready_model) || return 1
|
||||||
mode=$(jq -r .cli <<<"$lm")
|
if [ -n "$want" ]; then
|
||||||
|
# Only an agent the server lists as startable. A Codeman that lists none (older than
|
||||||
|
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
|
||||||
|
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
|
||||||
|
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
|
||||||
|
case "$r" in
|
||||||
|
ready) ;;
|
||||||
|
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
|
||||||
|
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
|
||||||
|
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
mode=$want
|
||||||
|
else
|
||||||
|
mode=$(jq -r .cli <<<"$lm")
|
||||||
|
fi
|
||||||
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
||||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
# Readiness is a token the agent's composer draws once it takes input. The server names
|
||||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
|
||||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
# For a server that sends none, the two this skill measured itself:
|
||||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
|
||||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
# at the mark was submitted every time).
|
||||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
|
||||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
|
||||||
[ "$mode" = opencode ] && mark='┃'
|
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
|
||||||
|
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
|
||||||
|
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
|
||||||
|
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
|
||||||
|
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
|
||||||
|
# claude takes the claude path below.
|
||||||
|
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
|
||||||
|
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
|
||||||
mark=${LOCAL_READY_MARK:-$mark}
|
mark=${LOCAL_READY_MARK:-$mark}
|
||||||
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
||||||
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
||||||
@@ -384,11 +439,16 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
||||||
# so a claude worker on the local model never gets one.
|
# so a claude worker on the local model never gets one.
|
||||||
|
# pi on the local model: a custom model session gives pi a home of its own, so it would
|
||||||
|
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
|
||||||
|
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
|
||||||
|
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
||||||
|
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
|
||||||
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
||||||
# The answers that want a PERSON to decide come back as success with no session id.
|
# The answers that want a PERSON to decide come back as success with no session id.
|
||||||
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
||||||
@@ -405,11 +465,18 @@ spawn_worker() {
|
|||||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
fi
|
fi
|
||||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
|
||||||
# why sendwait refuses it (400) and sendmark drives it.
|
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
|
||||||
|
# worker reports the end of a turn on the local model too, but sendmark is still the way
|
||||||
|
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
|
||||||
|
# new one `last-response` comes back empty for it.
|
||||||
r=$(_mark_up "$sid" "$mark" 45000)
|
r=$(_mark_up "$sid" "$mark" 45000)
|
||||||
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
||||||
delete_session "$sid" >/dev/null; return 1; }
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
|
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
|
||||||
|
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
|
||||||
|
delete_session "$sid" >/dev/null; return 1
|
||||||
|
fi
|
||||||
printf '%s\n' "$sid"; return 0
|
printf '%s\n' "$sid"; return 0
|
||||||
fi
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
@@ -425,6 +492,10 @@ spawn_worker() {
|
|||||||
r=$(_dsh_up "$sid" 45000)
|
r=$(_dsh_up "$sid" 45000)
|
||||||
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
|
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
|
||||||
delete_session "$sid" >/dev/null; return 1; }
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
|
if _dsh_guide "$sid"; then
|
||||||
|
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
|
||||||
|
delete_session "$sid" >/dev/null; return 1
|
||||||
|
fi
|
||||||
printf '%s\n' "$sid"; return 0
|
printf '%s\n' "$sid"; return 0
|
||||||
fi
|
fi
|
||||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
|
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
|
||||||
@@ -572,7 +643,7 @@ sendwait() {
|
|||||||
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
||||||
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
||||||
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
||||||
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
||||||
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
||||||
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
||||||
# it did it right, not even that the turn is over: a reasoning model can restate the
|
# it did it right, not even that the turn is over: a reasoning model can restate the
|
||||||
@@ -596,7 +667,12 @@ sendmark() {
|
|||||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||||
esac
|
esac
|
||||||
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
||||||
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
|
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
|
||||||
|
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
|
||||||
|
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
|
||||||
|
# calls started in one second for two workers in ONE folder shared a token, and one
|
||||||
|
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
|
||||||
|
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
|
||||||
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
||||||
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
||||||
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
||||||
@@ -683,10 +759,10 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.50.0
|
CODEMAN_PREAMBLE=1.51.0
|
||||||
PREAMBLE
|
PREAMBLE
|
||||||
)
|
)
|
||||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Every later Bash call that touches the API starts with the same two loader lines from
|
Every later Bash call that touches the API starts with the same two loader lines from
|
||||||
@@ -737,7 +813,7 @@ and no per-call body to hand-build.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
@@ -826,7 +902,8 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co
|
|||||||
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
|
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
|
||||||
instead of guessed-from-silence ones — and it writes a structured transcript, which is
|
instead of guessed-from-silence ones — and it writes a structured transcript, which is
|
||||||
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
|
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
|
||||||
`grok`, `omp` and `copilot` have neither, and `codex` has a transcript and a screen-read `blocked` but
|
`grok`, `omp`, `copilot` and `jcode` have neither (one exception: a local `omp` session reports
|
||||||
|
`blocked` for its tool approval dialogs, and still no `stop`), and `codex` has a transcript and a screen-read `blocked` but
|
||||||
no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
|
no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
|
||||||
|
|
||||||
Three things to know before you spawn one:
|
Three things to know before you spawn one:
|
||||||
@@ -850,9 +927,25 @@ Three things to know before you spawn one:
|
|||||||
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
|
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
|
||||||
on its welcome screen), you can hand work to that model from here. You stay the planner and
|
on its welcome screen), you can hand work to that model from here. You stay the planner and
|
||||||
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
|
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
|
||||||
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
|
on the installed model, run by its default coding agent (OpenCode on the Mac it was tested
|
||||||
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
|
on), and from then on it is the same verbs. It mixes into one fleet like any other mode:
|
||||||
other mode: `spawn_workers plan review:deepseek summ:local`.
|
`spawn_workers plan review:deepseek summ:local`.
|
||||||
|
|
||||||
|
**Which coding agent runs the worker is yours to choose.** `local_model` lists them as
|
||||||
|
`agents`, each with a `status`; `spawn_worker <name> local:<cli>` starts one that is `ready`:
|
||||||
|
|
||||||
|
| Worker kind | Agent | Why you would pick it | Drive it with |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `local` | the default one (`cli` of `local_model`) | you have no reason to choose | `sendmark` (`sendwait` when it is claude) |
|
||||||
|
| `local:opencode` | OpenCode | the tested pair; a full tool set | `sendmark` |
|
||||||
|
| `local:pi` | Pi | the smallest fixed prompt (about 2,100 tokens against 8,000 and more), so the first answer comes soonest and the most context is left for the work | `sendmark` |
|
||||||
|
| `local:deepseek` | DeepSeek Harness | the user already works with it | `sendmark` (it reports the end of a turn, but on dsh 0.2.2 its answer cannot be read back through `sendwait` yet) |
|
||||||
|
| `local:claude` | Claude Code | hooks and `sendwait`, at the price of a first answer that takes minutes | `sendwait` |
|
||||||
|
|
||||||
|
An agent whose status is `installable`, `installing` or `setup-needed` is not startable, and
|
||||||
|
the spawn says so with the ones that are. Installing an agent is a person's click in the
|
||||||
|
**Run a local model** window, like installing a model: tell the user, do not work around it.
|
||||||
|
A Codeman older than 1.51 lists no agents, and only plain `local` works there.
|
||||||
|
|
||||||
Reach for it when:
|
Reach for it when:
|
||||||
|
|
||||||
@@ -866,9 +959,9 @@ Reach for it when:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
|
local_model || exit 1 # one JSON line: model, agents, endpoint; or the reason there is none
|
||||||
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
|
SID=$(spawn_worker summ local) || exit 1 # or local:pi, local:deepseek; starts a stopped runtime first; empty = failed
|
||||||
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
|
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
|
||||||
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
|
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
|
||||||
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
|
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
|
||||||
@@ -891,8 +984,9 @@ The limits, before you promise the user anything:
|
|||||||
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
|
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
|
||||||
window. This skill never installs one: that is a download of many gigabytes the person
|
window. This skill never installs one: that is a download of many gigabytes the person
|
||||||
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
|
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
|
||||||
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
|
`install-unfinished`, `no-agent`, `blocked`, `admin-only`, `unsupported`), so tell the
|
||||||
instead of guessing.
|
user that instead of guessing. It also lists the models on this Mac (`models`); only the
|
||||||
|
`active` one runs, and switching to another is a person's decision in the window.
|
||||||
- **One model, one request at a time.** The runtime loads one model and answers one request
|
- **One model, one request at a time.** The runtime loads one model and answers one request
|
||||||
at a time, so two local workers take turns rather than work side by side. Fan out to the
|
at a time, so two local workers take turns rather than work side by side. Fan out to the
|
||||||
cloud; give the Mac a queue.
|
cloud; give the Mac a queue.
|
||||||
@@ -900,9 +994,20 @@ The limits, before you promise the user anything:
|
|||||||
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
|
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
|
||||||
loads the model first. A local worker run by claude spends minutes on its first answer
|
loads the model first. A local worker run by claude spends minutes on its first answer
|
||||||
(its fixed prompt is about 36,000 tokens).
|
(its fixed prompt is about 36,000 tokens).
|
||||||
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
|
- **Markers, not hooks.** OpenCode and Pi report no end of turn, so `sendwait` answers 400
|
||||||
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
|
for those workers; `sendmark` is the way through. A claude worker has hooks, so
|
||||||
has hooks and `sendwait` works as for any claude worker.
|
`sendwait` works for it. A DeepSeek Harness worker reports its turns too, but drive it
|
||||||
|
with `sendmark` on the local model: dsh 0.2.2 renamed its transcript file, and Codeman
|
||||||
|
reads an empty answer from it for now.
|
||||||
|
- **DeepSeek Harness has a setup guide a person finishes once.** Until then every dsh
|
||||||
|
session opens on it, and the spawn refuses the worker (and deletes it) instead of typing
|
||||||
|
a task into a sign-in screen. Tell the user to start one DeepSeek session from the Run
|
||||||
|
menu and finish the guide.
|
||||||
|
- **Pi starts without trusting the folder.** A `local:pi` worker is started with
|
||||||
|
`--no-approve`, so it loads no project extensions or skills from the case folder: on the
|
||||||
|
local model Pi would otherwise ask to trust the folder in every session, and a task
|
||||||
|
prompt must never answer that question. Pi also fetches its two search tools at each such
|
||||||
|
start, which needs the network for a moment.
|
||||||
- **Spawning can change what the person set.** A stopped runtime is started, which also
|
- **Spawning can change what the person set.** A stopped runtime is started, which also
|
||||||
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
|
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
|
||||||
open, the window refuses Stop and Remove and names your workers, so delete them as soon
|
open, the window refuses Stop and Remove and names your workers, so delete them as soon
|
||||||
|
|||||||
+102
-26
@@ -1,4 +1,4 @@
|
|||||||
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.51.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
|
||||||
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
|
||||||
# from that body (test/agent-skill.test.ts pins it).
|
# from that body (test/agent-skill.test.ts pins it).
|
||||||
@@ -59,6 +59,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
|
|||||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||||
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
|
||||||
}
|
}
|
||||||
|
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
|
||||||
|
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
|
||||||
|
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
|
||||||
|
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
|
||||||
|
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
|
||||||
|
# RENDERED pane with its whitespace removed, and never handed back while it shows.
|
||||||
|
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
|
||||||
|
| jq -r '.data.terminalBuffer // empty' \
|
||||||
|
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
|
||||||
|
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
|
||||||
|
}
|
||||||
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
|
||||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||||
# "No, exit" by default:
|
# "No, exit" by default:
|
||||||
@@ -121,9 +132,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
|
|||||||
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
|
||||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||||
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||||
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
|
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
|
||||||
|
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
|
||||||
|
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
|
||||||
|
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
|
||||||
|
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
|
||||||
|
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
|
||||||
|
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
|
||||||
|
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
|
||||||
|
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
|
||||||
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
|
||||||
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
|
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
|
||||||
|
# that can run the model is on this Mac), blocked (installed, but the server
|
||||||
# refuses a session now: the model changed on disk, another program holds the port, the
|
# refuses a session now: the model changed on disk, another program holds the port, the
|
||||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||||
# local models, 404), unauthorized (401, §6), unreachable, error.
|
# local models, 404), unauthorized (401, §6), unreachable, error.
|
||||||
@@ -139,12 +159,23 @@ local_model() {
|
|||||||
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
|
||||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||||
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
|
||||||
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
|
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
|
||||||
|
agents:(if ($i.harnesses | type) == "array"
|
||||||
|
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
|
||||||
|
else null end),
|
||||||
|
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
|
||||||
|
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
|
||||||
|
else null end)} as $base
|
||||||
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
|
||||||
elif $i.state == "installed" then
|
elif $i.state == "installed" then
|
||||||
|
# With the agents listed and none of them startable there is nothing to spawn,
|
||||||
|
# running or not: say that, not "blocked".
|
||||||
|
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
|
||||||
|
then {spawnable:false, reason:"no-agent",
|
||||||
|
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
|
||||||
# `start` alone over-promises: it is offered too while another program holds the
|
# `start` alone over-promises: it is offered too while another program holds the
|
||||||
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
# port. Only an error-free start is a runtime spawn_worker can bring up.
|
||||||
if ($i.cli and $i.endpointId and $i.model.id)
|
elif ($i.cli and $i.endpointId and $i.model.id)
|
||||||
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
|
||||||
then {spawnable:true} + $base
|
then {spawnable:true} + $base
|
||||||
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
|
||||||
@@ -207,31 +238,55 @@ _local_ready_model() {
|
|||||||
# rather than handed back, because a worker that never drew its composer would eat the
|
# rather than handed back, because a worker that never drew its composer would eat the
|
||||||
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
|
||||||
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
|
||||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
|
||||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
|
||||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
|
||||||
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
|
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
|
||||||
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
|
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
|
||||||
# never confirms on anyone's behalf.
|
# that wants a person to confirm something (a context below the agent's safe floor, a model
|
||||||
|
# swap) fails the spawn: this never confirms on anyone's behalf, and it never installs an
|
||||||
|
# agent. All local workers share ONE model that answers one request at a time.
|
||||||
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
|
||||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||||
# committing to an approach, on a recurring error and before declaring the task done.
|
# committing to an approach, on a recurring error and before declaring the task done.
|
||||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||||
spawn_worker() {
|
spawn_worker() {
|
||||||
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark=''
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
|
||||||
|
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
|
||||||
if [ "$mode" = local ]; then
|
if [ "$mode" = local ]; then
|
||||||
lm=$(_local_ready_model) || return 1
|
lm=$(_local_ready_model) || return 1
|
||||||
mode=$(jq -r .cli <<<"$lm")
|
if [ -n "$want" ]; then
|
||||||
|
# Only an agent the server lists as startable. A Codeman that lists none (older than
|
||||||
|
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
|
||||||
|
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
|
||||||
|
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
|
||||||
|
case "$r" in
|
||||||
|
ready) ;;
|
||||||
|
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
|
||||||
|
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
|
||||||
|
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
mode=$want
|
||||||
|
else
|
||||||
|
mode=$(jq -r .cli <<<"$lm")
|
||||||
|
fi
|
||||||
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
|
||||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
# Readiness is a token the agent's composer draws once it takes input. The server names
|
||||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
|
||||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
# For a server that sends none, the two this skill measured itself:
|
||||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
|
||||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
# at the mark was submitted every time).
|
||||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
|
||||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
|
||||||
[ "$mode" = opencode ] && mark='┃'
|
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
|
||||||
|
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
|
||||||
|
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
|
||||||
|
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
|
||||||
|
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
|
||||||
|
# claude takes the claude path below.
|
||||||
|
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
|
||||||
|
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
|
||||||
mark=${LOCAL_READY_MARK:-$mark}
|
mark=${LOCAL_READY_MARK:-$mark}
|
||||||
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
[ "$mode" = claude ] || [ -n "$mark" ] || {
|
||||||
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
|
||||||
@@ -246,11 +301,16 @@ spawn_worker() {
|
|||||||
# server clamps this back to `workspace-write` for an owner without the grant.
|
# server clamps this back to `workspace-write` for an owner without the grant.
|
||||||
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
|
||||||
# so a claude worker on the local model never gets one.
|
# so a claude worker on the local model never gets one.
|
||||||
|
# pi on the local model: a custom model session gives pi a home of its own, so it would
|
||||||
|
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
|
||||||
|
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
|
||||||
|
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
|
||||||
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
|
||||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
|
||||||
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
|
||||||
|
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} else {} end)
|
||||||
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
|
||||||
# The answers that want a PERSON to decide come back as success with no session id.
|
# The answers that want a PERSON to decide come back as success with no session id.
|
||||||
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
|
||||||
@@ -267,11 +327,18 @@ spawn_worker() {
|
|||||||
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
|
||||||
fi
|
fi
|
||||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
|
||||||
# why sendwait refuses it (400) and sendmark drives it.
|
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
|
||||||
|
# worker reports the end of a turn on the local model too, but sendmark is still the way
|
||||||
|
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
|
||||||
|
# new one `last-response` comes back empty for it.
|
||||||
r=$(_mark_up "$sid" "$mark" 45000)
|
r=$(_mark_up "$sid" "$mark" 45000)
|
||||||
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
|
||||||
delete_session "$sid" >/dev/null; return 1; }
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
|
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
|
||||||
|
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
|
||||||
|
delete_session "$sid" >/dev/null; return 1
|
||||||
|
fi
|
||||||
printf '%s\n' "$sid"; return 0
|
printf '%s\n' "$sid"; return 0
|
||||||
fi
|
fi
|
||||||
if [ "$mode" = deepseek ]; then
|
if [ "$mode" = deepseek ]; then
|
||||||
@@ -287,6 +354,10 @@ spawn_worker() {
|
|||||||
r=$(_dsh_up "$sid" 45000)
|
r=$(_dsh_up "$sid" 45000)
|
||||||
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
|
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
|
||||||
delete_session "$sid" >/dev/null; return 1; }
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
|
if _dsh_guide "$sid"; then
|
||||||
|
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
|
||||||
|
delete_session "$sid" >/dev/null; return 1
|
||||||
|
fi
|
||||||
printf '%s\n' "$sid"; return 0
|
printf '%s\n' "$sid"; return 0
|
||||||
fi
|
fi
|
||||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
|
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
|
||||||
@@ -434,7 +505,7 @@ sendwait() {
|
|||||||
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
|
||||||
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
|
||||||
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
|
||||||
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
# every call and every worker (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
|
||||||
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
|
||||||
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
|
||||||
# it did it right, not even that the turn is over: a reasoning model can restate the
|
# it did it right, not even that the turn is over: a reasoning model can restate the
|
||||||
@@ -458,7 +529,12 @@ sendmark() {
|
|||||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||||
esac
|
esac
|
||||||
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
|
||||||
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
|
# The token carries six characters of the WORKER's id on top of the second and $RANDOM.
|
||||||
|
# Those two repeat: in zsh (the shell Claude Code runs on a Mac) every subshell draws the
|
||||||
|
# SAME $RANDOM until the parent shell reads it, and this line is a subshell. Two sendmark
|
||||||
|
# calls started in one second for two workers in ONE folder shared a token, and one
|
||||||
|
# worker's answer file replaced the other's (seen on a Mac, 2026-11-12).
|
||||||
|
tok=$(printf 'm%x%04x%s' "$(date +%s)" "$RANDOM" "$(printf '%s' "$sid" | tr -cd 'a-f0-9' | cut -c1-6)"); mark="DONE_$tok"
|
||||||
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
|
||||||
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
|
||||||
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
|
||||||
@@ -545,4 +621,4 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.50.0
|
CODEMAN_PREAMBLE=1.51.0
|
||||||
@@ -216,7 +216,10 @@ you typed matches the moment it is typed.
|
|||||||
generic marker (`BUILD OK`) matched against stale text, either from `from=buffer`
|
generic marker (`BUILD OK`) matched against stale text, either from `from=buffer`
|
||||||
scanning an earlier run or from tmux replaying old screen content as fresh output on an
|
scanning an earlier run or from tmux replaying old screen content as fresh output on an
|
||||||
attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes
|
attach/resize/redraw. A unique-per-call token (`DONE_$RANDOM`) makes both `from` modes
|
||||||
safe.
|
safe. ⚠️ In a fan-out put something of the WORKER into it too (its id, the loop index): zsh,
|
||||||
|
the shell Claude Code runs on a Mac, hands every subshell the same `$RANDOM` until the
|
||||||
|
parent shell reads it, so `( … $RANDOM … ) &` twice draws one number. `sendmark` adds six
|
||||||
|
characters of the worker's id for that reason.
|
||||||
|
|
||||||
### 6. `jq` parse error instead of an `errorCode`
|
### 6. `jq` parse error instead of an `errorCode`
|
||||||
|
|
||||||
@@ -242,7 +245,7 @@ minutes, never retry the credential.
|
|||||||
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
||||||
returns is too early (verified live: empty on the first call, full prose seconds later).
|
returns is too early (verified live: empty on the first call, full prose seconds later).
|
||||||
It is also `""` before the worker's first completed turn, and permanently `""` for
|
It is also `""` before the worker's first completed turn, and permanently `""` for
|
||||||
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `copilot`, which write no transcript at
|
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot` and `jcode`, which write no transcript at
|
||||||
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
|
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
|
||||||
for the same reason claude does (the harness finalizes the assistant message just after
|
for the same reason claude does (the harness finalizes the assistant message just after
|
||||||
it reports `idle`), so poll it the same way.
|
it reports `idle`), so poll it the same way.
|
||||||
@@ -346,7 +349,7 @@ ESC=$(printf '\033')
|
|||||||
|
|
||||||
`POST /api/v1/quick-start` body (all optional):
|
`POST /api/v1/quick-start` body (all optional):
|
||||||
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
||||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot`; response is
|
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot|jcode`; response is
|
||||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||||
|
|
||||||
@@ -361,9 +364,10 @@ a soft default the worker can change with `/advisor`. Remote and docker cases re
|
|||||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||||
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
|
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
|
||||||
`GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
|
`GET /api/v1/pi/status`, `GET /api/v1/omp/status`, `GET /api/v1/copilot/status` and `GET /api/v1/jcode/status` each return `.data.{available, path}` (no session needed).
|
||||||
Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
|
Pi's, grok's, OMP's, Copilot's and jcode's also carry `.data.version`, because `pi` is a short generic name,
|
||||||
`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
|
`grok` is a name with npm squatters, `omp` is a similarly short name, `copilot` is a generic one, and an
|
||||||
|
unrelated npm package is also called `jcode`, so an unrelated
|
||||||
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
||||||
not version-shaped, so `available:false` there can mean "a different program of the same
|
not version-shaped, so `available:false` there can mean "a different program of the same
|
||||||
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
||||||
@@ -492,8 +496,8 @@ Quirks that will bite you:
|
|||||||
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
||||||
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
||||||
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
|
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
|
||||||
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`. ⚠️ **`shell` is NOT one of those**
|
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`. ⚠️ **`shell` is NOT one of those**
|
||||||
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
|
(`isExternalCliMode` covers only those external CLIs), so the parser does
|
||||||
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
|
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
|
||||||
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
|
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
|
||||||
a shell worker running `cat build.log` really does populate this. In practice it stays
|
a shell worker running `cat build.log` really does populate this. In practice it stays
|
||||||
@@ -657,7 +661,7 @@ Three bounded long-polls. Shared semantics:
|
|||||||
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
|
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
|
||||||
| `working` | session started producing output | every mode |
|
| `working` | session started producing output | every mode |
|
||||||
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude`; also `deepseek`, whose harness reports its own end of turn while its status bridge is on |
|
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude`; also `deepseek`, whose harness reports its own end of turn while its status bridge is on |
|
||||||
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `deepseek` (its harness reports its dialogs, same condition), and `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer both in the terminal, never through the inbox) |
|
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `deepseek` (its harness reports its dialogs, same condition), `codex`, whose approval, question and trust dialogs Codeman reads off its screen, and a local `omp` session, whose tool approval dialogs a hook module in its workspace reports (answer all three in the terminal, never through the inbox) |
|
||||||
| `exit` | PTY exited or session deleted | every mode |
|
| `exit` | PTY exited or session deleted | every mode |
|
||||||
|
|
||||||
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
|
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
|
|||||||
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
||||||
| liveness / death check | HTTP `wait?until=exit` |
|
| liveness / death check | HTTP `wait?until=exit` |
|
||||||
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
||||||
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`) | HTTP only (no other CLI has messaging) |
|
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`) | HTTP only (no other CLI has messaging) |
|
||||||
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
||||||
|
|
||||||
## Availability: probe, never assume
|
## Availability: probe, never assume
|
||||||
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
|
|||||||
|
|
||||||
### Mixed fleets: the pairing matrix
|
### Mixed fleets: the pairing matrix
|
||||||
|
|
||||||
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`) cannot be peers
|
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, `jcode`) cannot be peers
|
||||||
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
||||||
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
||||||
subject to the namespace rule: **messaging works between two sessions that share one
|
subject to the namespace rule: **messaging works between two sessions that share one
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||||
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot, which have no transcript, use
|
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot/jcode, which have no transcript, use
|
||||||
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
||||||
|
|
||||||
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
||||||
|
|||||||
@@ -383,12 +383,17 @@ recovered by submitting it with `{"input":"\r"}`.
|
|||||||
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
|
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
|
||||||
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
||||||
real end-of-turn signal rather than a guess. On
|
real end-of-turn signal rather than a guess. On
|
||||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`, requesting them explicitly is a
|
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`/`jcode`, requesting them explicitly is a
|
||||||
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
|
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
|
||||||
question or trust dialog is up; a wait for it answers at once if one already is), and
|
question or trust dialog is up; a wait for it answers at once if one already is; and
|
||||||
|
`blocked` on a local `omp` session, next paragraph), and
|
||||||
lifecycle transitions there are coarse (a short shell command may emit **no**
|
lifecycle transitions there are coarse (a short shell command may emit **no**
|
||||||
`idle` transition at all, verified live), so synchronize those with markers.
|
`idle` transition at all, verified live), so synchronize those with markers.
|
||||||
|
|
||||||
|
`blocked` also works on a local `omp` session: Codeman installs a hook module into its
|
||||||
|
workspace that reports omp's approval dialogs (the inbox item is alert-only, so answer in
|
||||||
|
the pane). `stop` stays a 400 there, so wait on `idle` or a marker for end-of-turn.
|
||||||
|
|
||||||
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
|
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
|
||||||
false` at create time disarms the bridge, and an explicit `until=stop` is then a 400
|
false` at create time disarms the bridge, and an explicit `until=stop` is then a 400
|
||||||
naming that setting. And a `stop` that is *accepted* is not proof it will ever fire —
|
naming that setting. And a `stop` that is *accepted* is not proof it will ever fire —
|
||||||
@@ -437,7 +442,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
|
|||||||
single read taken the instant send-and-wait returns comes back `""` even though the
|
single read taken the instant send-and-wait returns comes back `""` even though the
|
||||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`; the first four
|
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`, `jcode`; the first four
|
||||||
verified live, pi from the same source path), which is
|
verified live, pi from the same source path), which is
|
||||||
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
|
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
|
||||||
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
|
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
|
||||||
@@ -526,7 +531,7 @@ turn), and both better than diffing terminal samples:
|
|||||||
```
|
```
|
||||||
|
|
||||||
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
||||||
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`** (those parsers are skipped wholesale) and
|
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`** (those parsers are skipped wholesale) and
|
||||||
in practice empty for `shell`. Source-verified, not measured live.
|
in practice empty for `shell`. Source-verified, not measured live.
|
||||||
|
|
||||||
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
|
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
|
||||||
@@ -831,9 +836,19 @@ can be spawned now (`running: true`) or once `spawn_worker` has started the runt
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
|
||||||
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
|
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B",
|
||||||
|
"agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
|
||||||
|
{"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
|
||||||
|
"models":[{"id":"qwen3.6:35b-a3b","label":"Qwen3.6 35B-A3B","active":true,"status":"ready","sizeGb":"23.1"}]}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`cli` is the default coding agent. `agents` is every agent the model can be run with
|
||||||
|
(`install.harnesses` of the route): `ready` can be started, `installable` and `installing`
|
||||||
|
are an install a person makes or made in the window, `setup-needed` is an agent with a setup
|
||||||
|
of its own (DeepSeek Harness without a terminal profile). `models` is what is on the Mac
|
||||||
|
(`install.library`); only the `active` one runs. A Codeman older than 1.51 sends neither
|
||||||
|
list, and both keys are then absent.
|
||||||
|
|
||||||
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
|
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
|
||||||
|
|
||||||
| `reason` | What it means | What to tell the user |
|
| `reason` | What it means | What to tell the user |
|
||||||
@@ -841,6 +856,7 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
|
|||||||
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
|
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
|
||||||
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
|
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
|
||||||
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
|
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
|
||||||
|
| `no-agent` | installed, and `agents` holds none that is `ready` | install a coding agent in that window (a chip that says "Not installed") |
|
||||||
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
|
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
|
||||||
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
|
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
|
||||||
| `unsupported` | the server has no local model routes (404) | it predates the feature |
|
| `unsupported` | the server has no local model routes (404) | it predates the feature |
|
||||||
@@ -849,29 +865,44 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
|
|||||||
It never downloads, installs or starts anything. Installing is many gigabytes the person
|
It never downloads, installs or starts anything. Installing is many gigabytes the person
|
||||||
confirms in the window, and no route this skill calls can do it.
|
confirms in the window, and no route this skill calls can do it.
|
||||||
|
|
||||||
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), in order:
|
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), and
|
||||||
|
**`spawn_worker <name> local:<cli>`** to choose the coding agent, in order:
|
||||||
|
|
||||||
1. `local_model`; anything but exit 0 fails the spawn with that reason.
|
1. `local_model`; anything but exit 0 fails the spawn with that reason. With `local:<cli>`
|
||||||
|
the agent must be a row of `agents` whose status is `ready`; any other status, or an
|
||||||
|
agent the model cannot be run with, fails the spawn with the ones that are startable.
|
||||||
|
On a Codeman that lists no agents only its one `cli` can be named. No agent is ever
|
||||||
|
installed from here.
|
||||||
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
|
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
|
||||||
window's Start button sends, then `local_model` again until it reports `running`
|
window's Start button sends, then `local_model` again until it reports `running`
|
||||||
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
|
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
|
||||||
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
|
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
|
||||||
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
|
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
|
||||||
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
|
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
|
||||||
session", with the installed agent as the mode. No advisor, which is a cloud model.
|
session", with the chosen agent as the mode. No advisor, which is a cloud model. A Pi
|
||||||
|
worker also carries `"piConfig":{"approveProjectTrust":false}` (`--no-approve`): on a
|
||||||
|
custom model Pi keeps no home, so it would ask to trust the folder in every session with
|
||||||
|
Trust preselected, and the task prompt's Enter would answer it.
|
||||||
The server checks again that its own runtime runs and that the model on disk is the one
|
The server checks again that its own runtime runs and that the model on disk is the one
|
||||||
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
|
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
|
||||||
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
|
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
|
||||||
person (a context below the agent's safe floor, a model swap that would evict another
|
person (a context below the agent's safe floor, a model swap that would evict another
|
||||||
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
|
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
|
||||||
and no `confirmed*` flag is ever sent.
|
and no `confirmed*` flag is ever sent.
|
||||||
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
|
5. Readiness: a token the agent's composer draws once it takes input, through
|
||||||
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
|
`wait-output`, 45 s, and the session deleted on a miss. The token is the agent's
|
||||||
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
|
`readyMark` from `local_model` when the server sends one, else the one measured for this
|
||||||
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
|
skill, and `LOCAL_READY_MARK` overrides both. All of it was measured on Linux through a
|
||||||
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
|
custom model launch, none of it on a Mac yet:
|
||||||
dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its
|
|
||||||
composer draws, or the spawn is refused before a session exists.
|
| Agent | Token | What was measured |
|
||||||
|
|-------|-------|-------------------|
|
||||||
|
| OpenCode | `┃` | 1.3.0 and 1.19.5: nothing draws it before the composer, no first-run screen, and a prompt pasted at the first `┃` was submitted (2 of 2 on 1.19.5) |
|
||||||
|
| DeepSeek Harness | `❯`, then a look at the pane | 0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1: splash, composer and status line arrive in one frame; a prompt sent at the mark was submitted (2 of 2). The harness reports idle about 150 ms BEFORE that frame, so `stop` is no readiness signal. 0.2.2-rc.1 with dsh-TUI 0.19.0: a setup guide ("Step 1 of 4") opens in every session until a person has finished it once, and its cursor is `❯` too (the mark matched on it, 2 of 2), so after the mark the rendered pane is read and a worker that shows the guide is deleted, never handed back; with the guide finished, a prompt sent at the mark was submitted |
|
||||||
|
| Pi | `extended-keys` | 1.5.2: Pi paints its composer rules in its first frame and ignores Enter until startup has finished, so the rule `─` is NOT a mark (a prompt sent at it stayed in the composer, 2 of 2). The last line of its startup is a warning that tmux's `extended-keys` option is off, which it is on Codeman's socket; a prompt sent once that word showed was submitted (4 of 4). A side effect, not a contract: a Pi that stops printing it fails the spawn until `LOCAL_READY_MARK` names what it draws instead |
|
||||||
|
|
||||||
|
A claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
|
||||||
|
dialog). An agent with no token at all is refused before a session exists.
|
||||||
|
|
||||||
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
|
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
|
||||||
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
|
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
|
||||||
|
|||||||
@@ -190,11 +190,6 @@ export abstract class AiCheckerBase<
|
|||||||
*/
|
*/
|
||||||
protected abstract getNegativeVerdict(): V;
|
protected abstract getNegativeVerdict(): V;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the error verdict value.
|
|
||||||
*/
|
|
||||||
protected abstract getErrorVerdict(): V;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an error result.
|
* Create an error result.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -165,10 +165,6 @@ export class AiIdleChecker extends AiCheckerBase<AiCheckVerdict, AiIdleCheckConf
|
|||||||
return 'WORKING';
|
return 'WORKING';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getErrorVerdict(): AiCheckVerdict {
|
|
||||||
return 'ERROR';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected createErrorResult(reasoning: string, durationMs: number): AiCheckResult {
|
protected createErrorResult(reasoning: string, durationMs: number): AiCheckResult {
|
||||||
return { verdict: 'ERROR', reasoning, durationMs };
|
return { verdict: 'ERROR', reasoning, durationMs };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,10 +139,6 @@ export class AiPlanChecker extends AiCheckerBase<
|
|||||||
return 'NOT_PLAN_MODE';
|
return 'NOT_PLAN_MODE';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getErrorVerdict(): AiPlanCheckVerdict {
|
|
||||||
return 'ERROR';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected createErrorResult(reasoning: string, durationMs: number): AiPlanCheckResult {
|
protected createErrorResult(reasoning: string, durationMs: number): AiPlanCheckResult {
|
||||||
return { verdict: 'ERROR', reasoning, durationMs };
|
return { verdict: 'ERROR', reasoning, durationMs };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = {
|
|||||||
// per user), a session started in another case, every captured prompt, and a Docker case's
|
// per user), a session started in another case, every captured prompt, and a Docker case's
|
||||||
// conversation id after a /clear, and a web tab being opened.
|
// conversation id after a /clear, and a web tab being opened.
|
||||||
'users.json': ['lastLoginAt'],
|
'users.json': ['lastLoginAt'],
|
||||||
'settings.json': ['lastUsedCase'],
|
'settings.json': ['lastUsedCase', 'lastPickedCase'],
|
||||||
'intents.json': ['recentPrompts', 'updatedAt'],
|
'intents.json': ['recentPrompts', 'updatedAt'],
|
||||||
'docker-cases.json': ['lastClaudeSessionId'],
|
'docker-cases.json': ['lastClaudeSessionId'],
|
||||||
'webviews.json': ['lastOpenedAt'],
|
'webviews.json': ['lastOpenedAt'],
|
||||||
|
|||||||
@@ -126,9 +126,15 @@ export async function loadAttachmentGuardConfig(): Promise<AttachmentGuardConfig
|
|||||||
*
|
*
|
||||||
* NOTE: this is intentionally a SUPERSET of `isSensitivePath` so `/api/download`
|
* NOTE: this is intentionally a SUPERSET of `isSensitivePath` so `/api/download`
|
||||||
* behavior is NOT changed — only attachment registration/serving uses this.
|
* behavior is NOT changed — only attachment registration/serving uses this.
|
||||||
|
* `opts` passes through to `isSensitivePath` (the File Viewer editor's
|
||||||
|
* `allowDotenv`).
|
||||||
*/
|
*/
|
||||||
export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly string[]): boolean {
|
export function isBlockedAttachmentPath(
|
||||||
if (isSensitivePath(absPath)) return true;
|
absPath: string,
|
||||||
|
blockedTrees: readonly string[],
|
||||||
|
opts: { allowDotenv?: boolean } = {}
|
||||||
|
): boolean {
|
||||||
|
if (isSensitivePath(absPath, opts)) return true;
|
||||||
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
|
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
* @module config/cli-registry/argv
|
* @module config/cli-registry/argv
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ArgSpec, CliEntry, CliLaunch, Cond, EngineValue, ParamSpec, QuoteStyle } from './types.js';
|
import type { ArgSpec, CliLaunch, Cond, EngineValue, ParamSpec, QuoteStyle } from './types.js';
|
||||||
import { matchesPattern } from './patterns.js';
|
import { matchesPattern } from './patterns.js';
|
||||||
import { SAFE_BARE_TOKEN } from './patterns.js';
|
import { SAFE_BARE_TOKEN } from './patterns.js';
|
||||||
|
|
||||||
@@ -178,13 +178,3 @@ export function renderLaunch(
|
|||||||
|
|
||||||
return rendered.join(' || ');
|
return rendered.join(' || ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convenience: render an entry's launch command straight from a `CliEntry`. */
|
|
||||||
export function renderCliCommand(
|
|
||||||
entry: CliEntry,
|
|
||||||
params: ParamValues,
|
|
||||||
engineValues: EngineValues,
|
|
||||||
gatesPassed?: ReadonlySet<string>
|
|
||||||
): string {
|
|
||||||
return renderLaunch(entry.launch, params, engineValues, gatesPassed);
|
|
||||||
}
|
|
||||||
@@ -3,59 +3,4 @@
|
|||||||
* @module config/cli-registry
|
* @module config/cli-registry
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type {
|
export { enabledCliIds, getCli } from './registry.js';
|
||||||
ArgSpec,
|
|
||||||
CliCapabilities,
|
|
||||||
CliCredStore,
|
|
||||||
CliDiscovery,
|
|
||||||
CliEntry,
|
|
||||||
CliEnv,
|
|
||||||
CliId,
|
|
||||||
CliIdentityProbe,
|
|
||||||
CliLaunch,
|
|
||||||
CliOverlays,
|
|
||||||
CliRegistryFile,
|
|
||||||
CliVariant,
|
|
||||||
CliVersionProbe,
|
|
||||||
Cond,
|
|
||||||
EngineValue,
|
|
||||||
ParamSpec,
|
|
||||||
QuoteStyle,
|
|
||||||
} from './types.js';
|
|
||||||
export {
|
|
||||||
matchesPattern,
|
|
||||||
TOKEN_PATTERNS,
|
|
||||||
SAFE_BARE_TOKEN,
|
|
||||||
compileVersionRegex,
|
|
||||||
MAX_VERSION_OUTPUT,
|
|
||||||
} from './patterns.js';
|
|
||||||
export type { TokenPattern } from './patterns.js';
|
|
||||||
export { renderLaunch, renderCliCommand } from './argv.js';
|
|
||||||
export type { EngineValues, ParamValues } from './argv.js';
|
|
||||||
export { CliEntrySchema } from './schema.js';
|
|
||||||
export type { ValidatedCliEntry } from './schema.js';
|
|
||||||
export { STOCK_CLIS } from './stock.js';
|
|
||||||
export {
|
|
||||||
asCliId,
|
|
||||||
cliIds,
|
|
||||||
enabledCliIds,
|
|
||||||
enabledClis,
|
|
||||||
getCli,
|
|
||||||
listClis,
|
|
||||||
loadCliRegistry,
|
|
||||||
reloadCliRegistry,
|
|
||||||
resolveInstallCommandForPlatform,
|
|
||||||
resolveRegistry,
|
|
||||||
} from './registry.js';
|
|
||||||
export type { LoadResult } from './registry.js';
|
|
||||||
export {
|
|
||||||
COMPOSER_ANCHOR_KINDS,
|
|
||||||
isKnownLauncherProfile,
|
|
||||||
isKnownPredictProfile,
|
|
||||||
isKnownSetenvProfile,
|
|
||||||
LAUNCHER_PROFILE_NAMES,
|
|
||||||
PREDICT_PROFILES,
|
|
||||||
SETENV_PROFILE_NAMES,
|
|
||||||
TRANSCRIPT_READER_NAMES,
|
|
||||||
} from './profiles.js';
|
|
||||||
export type { LauncherProfileName, SetenvProfileName } from './profiles.js';
|
|
||||||
@@ -77,16 +77,10 @@ export type LauncherProfileName = (typeof LAUNCHER_PROFILE_NAMES)[number];
|
|||||||
export type SetenvProfileName = (typeof SETENV_PROFILE_NAMES)[number];
|
export type SetenvProfileName = (typeof SETENV_PROFILE_NAMES)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transcript readers, selected via `capabilities.transcript`. Unlike the profile registries
|
* True when `name` is a predictive-echo profile this build actually implements.
|
||||||
* above this one is closed over the schema enum itself rather than an open string, since
|
* Nothing calls it yet; it stays beside the registry it reads.
|
||||||
* transcript format is a small, genuinely fixed set — see CliCapabilities['transcript'].
|
* @knipignore
|
||||||
*/
|
*/
|
||||||
export const TRANSCRIPT_READER_NAMES = ['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'none'] as const;
|
|
||||||
|
|
||||||
/** Composer-row finders, selected via `capabilities.echo.anchor.kind`. Also schema-closed. */
|
|
||||||
export const COMPOSER_ANCHOR_KINDS = ['glyph', 'cursor', 'none'] as const;
|
|
||||||
|
|
||||||
/** True when `name` is a predictive-echo profile this build actually implements. */
|
|
||||||
export function isKnownPredictProfile(name: string | undefined): boolean {
|
export function isKnownPredictProfile(name: string | undefined): boolean {
|
||||||
return name !== undefined && Object.prototype.hasOwnProperty.call(PREDICT_PROFILES, name);
|
return name !== undefined && Object.prototype.hasOwnProperty.call(PREDICT_PROFILES, name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,18 +32,10 @@
|
|||||||
|
|
||||||
import { existsSync, readFileSync, renameSync, statSync } from 'node:fs';
|
import { existsSync, readFileSync, renameSync, statSync } from 'node:fs';
|
||||||
import { dataPath } from '../instance.js';
|
import { dataPath } from '../instance.js';
|
||||||
import type { CliEntry, CliId, CliRegistryFile } from './types.js';
|
import type { CliEntry, CliRegistryFile } from './types.js';
|
||||||
import { CliEntrySchema } from './schema.js';
|
import { CliEntrySchema } from './schema.js';
|
||||||
import { STOCK_CLIS } from './stock.js';
|
import { STOCK_CLIS } from './stock.js';
|
||||||
|
|
||||||
/** Construct a validated CliId. Throws if `raw` is not a well-formed id — call at API boundaries. */
|
|
||||||
export function asCliId(raw: string): CliId {
|
|
||||||
if (!/^[a-z][a-z0-9-]{0,23}$/.test(raw)) {
|
|
||||||
throw new Error(`invalid CLI id: ${JSON.stringify(raw)}`);
|
|
||||||
}
|
|
||||||
return raw as CliId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filePath(): string {
|
function filePath(): string {
|
||||||
return dataPath('clis.json');
|
return dataPath('clis.json');
|
||||||
}
|
}
|
||||||
@@ -227,10 +219,6 @@ export function getCli(id: string): CliEntry | undefined {
|
|||||||
return listClis().find((e) => (e.id as string) === id);
|
return listClis().find((e) => (e.id as string) === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cliIds(): string[] {
|
|
||||||
return listClis().map((e) => e.id as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every enabled entry's id, in registry order. */
|
/** Every enabled entry's id, in registry order. */
|
||||||
export function enabledCliIds(): string[] {
|
export function enabledCliIds(): string[] {
|
||||||
return enabledClis().map((e) => e.id as string);
|
return enabledClis().map((e) => e.id as string);
|
||||||
|
|||||||
@@ -314,6 +314,8 @@ const capabilitiesSchema = z
|
|||||||
// Keep the PTY's rows while a phone keyboard is up (inline renderers only).
|
// Keep the PTY's rows while a phone keyboard is up (inline renderers only).
|
||||||
// Optional: absent means the keyboard resizes, the safe default.
|
// Optional: absent means the keyboard resizes, the safe default.
|
||||||
keyboardHold: z.literal('inline').optional(),
|
keyboardHold: z.literal('inline').optional(),
|
||||||
|
// Spare the CLI's own detached daemon (another process group) when a pane is killed.
|
||||||
|
sharedDaemon: z.boolean().optional(),
|
||||||
keyboardAccessory: z.enum(['agent', 'shell']),
|
keyboardAccessory: z.enum(['agent', 'shell']),
|
||||||
privilegedCommandGate: z.boolean(),
|
privilegedCommandGate: z.boolean(),
|
||||||
startMode: z.enum(['interactive', 'shell']),
|
startMode: z.enum(['interactive', 'shell']),
|
||||||
@@ -325,6 +327,15 @@ const capabilitiesSchema = z
|
|||||||
// A closed list on purpose: it decides where the server WRITES, and this file is
|
// A closed list on purpose: it decides where the server WRITES, and this file is
|
||||||
// user-editable, so it can never be a free path (`..`, absolute).
|
// user-editable, so it can never be a free path (`..`, absolute).
|
||||||
agentSkillDir: z.enum(AGENT_SKILL_DIRS).optional(),
|
agentSkillDir: z.enum(AGENT_SKILL_DIRS).optional(),
|
||||||
|
// The hook module Codeman installs for a CLI whose hooks are files, not a settings
|
||||||
|
// block (omp). Not a closed list like `agentSkillDir`: any bare `.ts` file name is
|
||||||
|
// taken, and nothing else. The name is written into the user's workspace, so a path
|
||||||
|
// (`../../evil.ts`, an absolute one) must be refused by the schema rather than by
|
||||||
|
// whatever calls the writer.
|
||||||
|
approvalHookFile: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[A-Za-z0-9._-]+\.ts$/)
|
||||||
|
.optional(),
|
||||||
statusLineTelemetry: z.boolean(),
|
statusLineTelemetry: z.boolean(),
|
||||||
// How many columns this CLI indents its transcript body by, so a copy can take
|
// How many columns this CLI indents its transcript body by, so a copy can take
|
||||||
// that much off the clipboard. Bounded, because it is the whole strip: a copy
|
// that much off the clipboard. Bounded, because it is the whole strip: a copy
|
||||||
@@ -806,5 +817,3 @@ export const CliEntrySchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ValidatedCliEntry = z.infer<typeof CliEntrySchema>;
|
|
||||||
@@ -1661,9 +1661,11 @@ const DEEPSEEK: CliEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// OMP (`omp`, omp.sh). The plainest entry in the catalog after opencode: no permission
|
// OMP (`omp`, omp.sh). The plainest entry in the catalog after opencode: no permission
|
||||||
// flags at all — omp reads its model routing and hooks from `~/.omp/agent`, so the CLI's
|
// flags at all — omp reads its model routing, approval mode and user hooks from
|
||||||
// own config governs and there is deliberately nothing bypass-shaped to clamp. Its only
|
// `~/.omp/agent`, so the CLI's own config governs and there is deliberately nothing
|
||||||
// privileged surface is a pair of ENV keys (see privilegedEnvKeys below).
|
// bypass-shaped to clamp. The one thing Codeman adds is a project hook module that
|
||||||
|
// reports approval dialogs (`approvalHookFile` below). Its only privileged surface is a
|
||||||
|
// pair of ENV keys (see privilegedEnvKeys below).
|
||||||
const OMP: CliEntry = {
|
const OMP: CliEntry = {
|
||||||
id: 'omp' as CliEntry['id'],
|
id: 'omp' as CliEntry['id'],
|
||||||
label: 'OMP',
|
label: 'OMP',
|
||||||
@@ -1742,6 +1744,19 @@ const OMP: CliEntry = {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
...agentDefaults(),
|
...agentDefaults(),
|
||||||
...AGENTS_DIR_SKILL,
|
...AGENTS_DIR_SKILL,
|
||||||
|
// omp emits REAL approval events (`tool_approval_requested` / `tool_approval_resolved`),
|
||||||
|
// so an omp pane can report a pending dialog first-hand instead of by inference.
|
||||||
|
// Codeman installs the hook module named by `approvalHookFile` into
|
||||||
|
// `<workspace>/.claude/hooks/pre/` when a session is created (both create paths); it
|
||||||
|
// forwards those two events to /api/hook-event as `permission_prompt` /
|
||||||
|
// `elicitation_complete`, so the tab alert, the desktop notification and an ALERT-ONLY
|
||||||
|
// Approvals Inbox item (answer in the terminal) work.
|
||||||
|
// ⚠️ `hooks` stays 'none' (inherited from agentDefaults): that value means Claude's full
|
||||||
|
// hook set (`stop`, `idle_prompt`, answerable dialogs, `.claude/settings.local.json`),
|
||||||
|
// and omp has none of it. `approvalHookFile` is the separate, narrower capability;
|
||||||
|
// `approvalHookAvailableForMode()` is its predicate. Idle stays screen-read via
|
||||||
|
// `workDetect` below.
|
||||||
|
approvalHookFile: 'codeman-approval.ts',
|
||||||
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
|
// Fullscreen alt-screen TUI, same shape as antigravity/grok: only the
|
||||||
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
||||||
altScreen: 'strip-mux-only',
|
altScreen: 'strip-mux-only',
|
||||||
@@ -1763,7 +1778,8 @@ const OMP: CliEntry = {
|
|||||||
promptGlyph: '╰─',
|
promptGlyph: '╰─',
|
||||||
workingLine: '[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] [0-9hms ]+> |⎋ Working',
|
workingLine: '[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] [0-9hms ]+> |⎋ Working',
|
||||||
},
|
},
|
||||||
// No permission prompts and no bypass flag, so nothing config-shaped to clamp — the
|
// No permission FLAGS (omp's approval mode lives in its own config, `tools.approvalMode`)
|
||||||
|
// and no bypass flag, so nothing config-shaped to clamp — the
|
||||||
// whole privileged surface here is env-shaped.
|
// whole privileged surface here is env-shaped.
|
||||||
privilegedParams: [],
|
privilegedParams: [],
|
||||||
// Where omp resolves its auth from. No known concrete exfiltration path today (omp
|
// Where omp resolves its auth from. No known concrete exfiltration path today (omp
|
||||||
@@ -2023,6 +2039,142 @@ const COPILOT: CliEntry = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// jcode (1jehuang/jcode, a Rust coding-agent TUI). Measured on a live v0.98.1 pane inside tmux
|
||||||
|
// (capture-pane every 200 ms through real turns, a recorded tmux client stream, PageUp/PageDown
|
||||||
|
// and SGR wheel reports, a bracketed-paste prompt; 2026-10-16). Three things about it shape
|
||||||
|
// this entry and are worth knowing before changing it:
|
||||||
|
//
|
||||||
|
// 1. Every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`, started by the
|
||||||
|
// first client in its own session, so it outlives the pane that started it). The agent and
|
||||||
|
// its tools run in the DAEMON, with the daemon's environment: a second client's env never
|
||||||
|
// reaches its bash tool (measured, `probe=[]`). A session's `CODEMAN_SESSION_ID` and
|
||||||
|
// `CODEMAN_API_URL` are therefore whatever the session that started the daemon had, or
|
||||||
|
// nothing. That is why the agent skill is not injected (below) and why `JCODE_*` is not an
|
||||||
|
// `envOverrides` prefix: most of that namespace is read by the daemon, so a per-session
|
||||||
|
// value would do nothing, and where it did act (the session that starts the daemon) it
|
||||||
|
// would reach every jcode session, hook and command-path variables included.
|
||||||
|
// 2. It has no approval prompts and no bypass flag: bash runs ungated apart from jcode's own
|
||||||
|
// model-side reflection gate and a catastrophic-path deny. Nothing to clamp, nothing to
|
||||||
|
// read as blocked, same shape as pi.
|
||||||
|
// 3. A fresh install opens an onboarding wizard instead of the composer until its launch
|
||||||
|
// counter passes 5 (or 10 sessions exist). A prompt typed into it is lost, so a new user
|
||||||
|
// should run `jcode` once in a terminal (or press Esc in the pane) before driving it.
|
||||||
|
const JCODE: CliEntry = {
|
||||||
|
id: 'jcode' as CliEntry['id'],
|
||||||
|
label: 'jcode',
|
||||||
|
shortBadge: 'JC',
|
||||||
|
// No brand colour to borrow (the mark is a monochrome halftone donut, and its TUI's own blue
|
||||||
|
// is gemini's #8ab4f8), so an amber no other entry uses.
|
||||||
|
accent: '#f59e0b',
|
||||||
|
enabled: true,
|
||||||
|
stock: true,
|
||||||
|
order: 110,
|
||||||
|
kind: 'agent',
|
||||||
|
discovery: {
|
||||||
|
binaries: ['jcode'],
|
||||||
|
// The official installer links `~/.local/bin/jcode` (its `JCODE_INSTALL_DIR` default) to a
|
||||||
|
// launcher script under `~/.jcode/builds/`; the agent image puts it in `/usr/local/bin`.
|
||||||
|
searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.homeBin],
|
||||||
|
// `jcode --version` prints `jcode v0.98.1 (1c7426eb1)`. npm carries an unrelated `jcode`
|
||||||
|
// package, so the probe is anchored on the `jcode v` prefix and a mismatch counts as absent.
|
||||||
|
version: { arg: '--version', regex: '(?:^|\\s)jcode v(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true },
|
||||||
|
install: {
|
||||||
|
command: {
|
||||||
|
linux: 'curl -fsSL https://jcode.sh/install | bash',
|
||||||
|
darwin: 'curl -fsSL https://jcode.sh/install | bash',
|
||||||
|
},
|
||||||
|
docsUrl: 'https://jcode.sh/docs',
|
||||||
|
agentImageLayer: {
|
||||||
|
kind: 'dedicated',
|
||||||
|
reason:
|
||||||
|
'Not on npm: jcode ships a standalone Rust binary through jcode.sh/install, whose launcher is a script that execs the binary beside it.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
launch: {
|
||||||
|
params: {
|
||||||
|
model: { type: 'token', pattern: 'model' },
|
||||||
|
// jcode ids look like `session_fox_1792160401272_f5b47ce47d631006` and `--resume` also
|
||||||
|
// takes the short name (`fox`). Its value is OPTIONAL (bare `--resume` opens the session
|
||||||
|
// picker), so the token must not be able to start with `-`, or it would be read as a
|
||||||
|
// flag of its own: `path-segment` requires a leading alphanumeric.
|
||||||
|
resumeId: { type: 'token', pattern: 'path-segment' },
|
||||||
|
},
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'default',
|
||||||
|
args: [
|
||||||
|
{ lit: 'jcode' },
|
||||||
|
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
|
||||||
|
{ flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legacyConfigAliases: { resumeId: 'resumeSessionId' },
|
||||||
|
legacyConfigField: 'jcodeConfig',
|
||||||
|
resumeAppend: { style: 'flag', flag: '--resume' },
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
|
||||||
|
unset: ['NO_COLOR'],
|
||||||
|
// Provider logins live in jcode's own files (`~/.jcode/auth.json`, `~/.config/jcode/*.env`)
|
||||||
|
// and are read by the daemon, so there is nothing for the server to forward into the pane.
|
||||||
|
tmuxSetenvKeys: [],
|
||||||
|
dockerExecEnvNames: [],
|
||||||
|
// Deliberately none: see note 1 above the entry.
|
||||||
|
allowedPrefixes: [],
|
||||||
|
allowedKeys: [],
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
...agentDefaults(),
|
||||||
|
// A full-screen alternate-screen TUI that turns mouse tracking on itself (tmux
|
||||||
|
// `alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`), the opencode shape: kept, a
|
||||||
|
// drag would be reported to jcode instead of selecting text, so Auto Copy and copy-on-select
|
||||||
|
// would silently do nothing.
|
||||||
|
altScreen: 'strip-mux-and-mouse',
|
||||||
|
// Pages its own transcript on PageUp/PageDown (measured: PageUp brought the welcome header
|
||||||
|
// back, PageDown returned to the composer). Over the hollow alt-screen buffer the wheel and a
|
||||||
|
// swipe become those keys instead of doing nothing.
|
||||||
|
transcriptPageKeys: true,
|
||||||
|
// Closing a jcode tab must not take the shared daemon (note 1) down with it: it is a
|
||||||
|
// descendant of whichever pane started it, in a process group of its own (setsid).
|
||||||
|
// Measured before this flag: deleting that tab left the next tab's client looping on
|
||||||
|
// `Connection lost - retrying` with its prompt queued.
|
||||||
|
sharedDaemon: true,
|
||||||
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||||
|
// At rest the composer row is `<n>> ` (the prompt number, then `>`); while a turn runs the
|
||||||
|
// same row reads `<n>…` and a status row appears directly above it, a braille spinner (the
|
||||||
|
// gemini/omp frame set, ~12 fps) then the phase and the elapsed time:
|
||||||
|
// `⠋ sending… 0s`, `⠦ thinking… 2s · https`, `⠼ waiting for response… 3s · https`,
|
||||||
|
// `⠸ running bash · <detail> · 12s · Alt+B bg`, or, while streaming, the time first
|
||||||
|
// (`⠼ 12s · 45.1 tps · ↑16k ↓20`). Past a minute the time reads `1m 3s`, past an hour
|
||||||
|
// `1h 2m`. A dropped connection swaps the spinner for `↻ network disconnected, waiting to
|
||||||
|
// retry · … · 5s`, still mid-turn. At rest that row is empty or a dim tip, never a spinner.
|
||||||
|
// The spinner repaints the pane at least every 100 ms for the whole turn (recorded tmux
|
||||||
|
// client stream), so the pane is never quiet mid-turn, and at rest it sends nothing at all
|
||||||
|
// (12 s recorded: zero bytes), so the idle check settles.
|
||||||
|
// The glyph is the bare `>`: the turn-ending repaint redraws only the cells that changed,
|
||||||
|
// which on that row is the `…` turning into `>` (recorded: `\x1b[43;2H...> `), never the
|
||||||
|
// prompt number. It only ARMS the idle check. It also leaves the submit verifier inert,
|
||||||
|
// which is what jcode wants (no early-Enter defect): every composer row starts with its
|
||||||
|
// number, so no screen row starts with `>` the way the verifier requires.
|
||||||
|
workDetect: {
|
||||||
|
promptGlyph: '>',
|
||||||
|
workingLine: String.raw`(?:^|\n) *(?:[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] [^\n]*\d[smh]\b|↻ network disconnected)`,
|
||||||
|
},
|
||||||
|
// jcode reads `./.agents/skills` (and `./.jcode/skills`, `./.claude/skills`), but its tools
|
||||||
|
// run in the shared daemon, whose CODEMAN_* identity belongs to whichever session started
|
||||||
|
// it (note 1). An injected skill would act as THAT session, so it is not injected.
|
||||||
|
agentSkillInjection: false,
|
||||||
|
customModelInjection: { kind: 'unsupported' },
|
||||||
|
},
|
||||||
|
overlays: {
|
||||||
|
// No credStore: Docker seeding is CRED_STORES in docker-hosts.ts, which has no `.jcode` row.
|
||||||
|
// `~/.jcode` holds the OAuth tokens beside 160 MB of builds, sessions and SQLite caches, and
|
||||||
|
// API keys live in `~/.config/jcode`; sign in inside the container instead.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** The full stock catalog, in the order the run menu shows by default. */
|
/** The full stock catalog, in the order the run menu shows by default. */
|
||||||
export const STOCK_CLIS: CliEntry[] = [
|
export const STOCK_CLIS: CliEntry[] = [
|
||||||
CLAUDE,
|
CLAUDE,
|
||||||
@@ -2036,4 +2188,5 @@ export const STOCK_CLIS: CliEntry[] = [
|
|||||||
DEEPSEEK,
|
DEEPSEEK,
|
||||||
OMP,
|
OMP,
|
||||||
COPILOT,
|
COPILOT,
|
||||||
|
JCODE,
|
||||||
];
|
];
|
||||||
@@ -508,8 +508,11 @@ export interface CliCapabilities {
|
|||||||
* Whether `stop`/`blocked` wait signals can ever fire for this CLI.
|
* Whether `stop`/`blocked` wait signals can ever fire for this CLI.
|
||||||
*
|
*
|
||||||
* ⚠️ A TRI-STATE, not a boolean, because for one CLI this is a per-SESSION question:
|
* ⚠️ A TRI-STATE, not a boolean, because for one CLI this is a per-SESSION question:
|
||||||
* 'none' — no hook signals, ever (every external CLI, and `shell`).
|
* 'none' — no hook signals, ever (every external CLI, and `shell`). A CLI can
|
||||||
* 'always' — the CLI installs Codeman's hooks (claude).
|
* still report approval dialogs alone through `approvalHookFile` below.
|
||||||
|
* 'always' — the CLI installs Codeman's hooks (claude). Every consumer reads this
|
||||||
|
* as Claude's FULL hook set: `stop`, `idle_prompt`, answerable inbox
|
||||||
|
* items, and a `.claude/settings.local.json` block in the workspace.
|
||||||
* 'supervised' — the CLI REPORTS its own idle/working/blocked state to a supervisor
|
* 'supervised' — the CLI REPORTS its own idle/working/blocked state to a supervisor
|
||||||
* over a generic env-gated contract, and Codeman is that supervisor
|
* over a generic env-gated contract, and Codeman is that supervisor
|
||||||
* (deepseek, via deepseek-status-shim.ts). Definitive rather than
|
* (deepseek, via deepseek-status-shim.ts). Definitive rather than
|
||||||
@@ -523,6 +526,21 @@ export interface CliCapabilities {
|
|||||||
* timeout the predicate exists to prevent.
|
* timeout the predicate exists to prevent.
|
||||||
*/
|
*/
|
||||||
hooks: 'none' | 'always' | 'supervised';
|
hooks: 'none' | 'always' | 'supervised';
|
||||||
|
/**
|
||||||
|
* The hook MODULE Codeman installs into the workspace of a CLI that reports its
|
||||||
|
* approval dialogs, and nothing else, through hooks. Independent of `hooks` above.
|
||||||
|
*
|
||||||
|
* omp is the case that exists today: it loads `<cwd>/.claude/hooks/pre/*.ts`, and the
|
||||||
|
* installed module forwards `tool_approval_requested`/`tool_approval_resolved` to
|
||||||
|
* `/api/hook-event` as `permission_prompt`/`elicitation_complete`. What that earns
|
||||||
|
* (`approvalHookAvailableForMode()`): the `blocked` wait signal, a tab alert, a
|
||||||
|
* notification and an ALERT-ONLY inbox item (`answerInTerminal`). Never `stop`, never
|
||||||
|
* `idle_prompt`, never Approve/Deny keystrokes.
|
||||||
|
*
|
||||||
|
* Declared as data, not a branch on the id, so both create paths can ask
|
||||||
|
* "does this CLI need a hook file installed?" (see cli-registry-no-id-branching).
|
||||||
|
*/
|
||||||
|
approvalHookFile?: string;
|
||||||
/**
|
/**
|
||||||
* Which transcript reader, if any, understands this CLI's on-disk history.
|
* Which transcript reader, if any, understands this CLI's on-disk history.
|
||||||
*
|
*
|
||||||
@@ -622,6 +640,19 @@ export interface CliCapabilities {
|
|||||||
* losing lines, so it resizes as before), and for remote and docker sessions.
|
* losing lines, so it resizes as before), and for remote and docker sessions.
|
||||||
*/
|
*/
|
||||||
keyboardHold?: 'inline';
|
keyboardHold?: 'inline';
|
||||||
|
/**
|
||||||
|
* The CLI's TUI is a CLIENT of a per-user daemon that its first client starts and every
|
||||||
|
* later session of the CLI shares (jcode). The daemon detaches into its own session and
|
||||||
|
* process group, but stays a descendant of that first pane by parent pid, so a session
|
||||||
|
* kill signals only the descendants still in the pane's process group
|
||||||
|
* (`descendantsInPaneGroup()`, proc-tree.ts). Without it, closing the tab that happened
|
||||||
|
* to start the daemon took it down and cut every other session of the CLI off.
|
||||||
|
*
|
||||||
|
* The flip side is that processes the agent started through that daemon (its tools run
|
||||||
|
* there, not in the pane) outlive the tab; stopping them is the CLI's own business
|
||||||
|
* (`jcode server stop`). Absent = every descendant is killed, as for every other CLI.
|
||||||
|
*/
|
||||||
|
sharedDaemon?: boolean;
|
||||||
keyboardAccessory: 'agent' | 'shell';
|
keyboardAccessory: 'agent' | 'shell';
|
||||||
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
|
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
|
||||||
privilegedCommandGate: boolean;
|
privilegedCommandGate: boolean;
|
||||||
|
|||||||
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user