mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-12 18:19:33 +01:00
Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88ed3eef37 | ||
|
|
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 | ||
|
|
f8ff431d30 | ||
|
|
2a876731f6 | ||
|
|
7602b6db21 | ||
|
|
a50d0db283 | ||
|
|
d62b11586b | ||
|
|
84a6896cae |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
The path picker's image preview can now be zoomed: pinch and drag on a phone, the mouse wheel or a trackpad on a desktop, double-click to toggle 2x, and zoom out / fit / zoom in buttons. A small image is shown at its own size instead of being stretched to fill the preview. Thanks to @shenlvkang-collab, whose #608 this comes from.
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
# 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
|
||||
|
||||
### 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.
|
||||
|
||||
**Version**: 1.50.0 (must match `package.json`)
|
||||
**Version**: 1.51.0 (must match `package.json`)
|
||||
|
||||
## 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.
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -128,7 +128,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)
|
||||
|
||||
**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`
|
||||
|
||||
@@ -160,6 +160,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-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)
|
||||
|
||||
**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 +184,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`
|
||||
|
||||
**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)
|
||||
|
||||
**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)
|
||||
|
||||
⚠️ **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 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. ⚠️ 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`
|
||||
|
||||
@@ -210,13 +212,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)
|
||||
|
||||
**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)
|
||||
|
||||
**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/`.
|
||||
|
||||
@@ -241,7 +243,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)
|
||||
|
||||
**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)
|
||||
|
||||
@@ -265,7 +269,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`
|
||||
|
||||
**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)
|
||||
|
||||
@@ -328,7 +332,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `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.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h2 align="center">Mission control for AI coding agents</h2>
|
||||
|
||||
<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 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">
|
||||
</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):
|
||||
|
||||
@@ -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).
|
||||
|
||||
- **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
|
||||
- **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
|
||||
@@ -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).
|
||||
- **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
|
||||
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
|
||||
|
||||
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**.
|
||||
|
||||
@@ -290,7 +290,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
|
||||
| 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**). |
|
||||
| **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. |
|
||||
| **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 |
|
||||
| **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
|
||||
|
||||
<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
|
||||
- **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).
|
||||
- **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)
|
||||
- **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)
|
||||
@@ -977,7 +979,7 @@ for _ in $(seq 1 10); do
|
||||
done
|
||||
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
|
||||
# is empty for every tmux-backed (i.e. every interactive) session. tail counts
|
||||
# BYTES, and what comes back is terminal data, ANSI included.
|
||||
@@ -1218,7 +1220,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
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>"]
|
||||
end
|
||||
end
|
||||
|
||||
+9
-9
@@ -5,7 +5,7 @@
|
||||
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
||||
|
||||
<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 align="center">
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
> 本文档由英文版 [`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):
|
||||
|
||||
@@ -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-应用)。
|
||||
|
||||
- **一个仪表盘,十个 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),即时本地回显、二维码登录、滑动导航与推送通知
|
||||
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器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)。
|
||||
- **给急性子的参数。** `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
|
||||
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。在还没有 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 会问一次是否打开这个从互联网下载的应用:点**打开**。
|
||||
|
||||
@@ -292,7 +292,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
|
||||
| 字段 | 作用 |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **工作目录 / 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` 依然有效。 |
|
||||
| **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 绝不收集或保存凭据
|
||||
- **在自定义文件夹里创建 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)。
|
||||
- **多 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)
|
||||
- **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)
|
||||
@@ -971,7 +971,7 @@ for _ in $(seq 1 10); do
|
||||
done
|
||||
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 承载的
|
||||
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
||||
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
||||
@@ -1192,7 +1192,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
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>"]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -305,5 +305,34 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "io.github.Ark0N/codeman",
|
||||
"title": "Codeman",
|
||||
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"websiteUrl": "https://getcodeman.com",
|
||||
"repository": {
|
||||
"url": "https://github.com/Ark0N/Codeman",
|
||||
@@ -13,7 +13,7 @@
|
||||
{
|
||||
"registryType": "npm",
|
||||
"identifier": "aicodeman",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/wrapped-file-paths.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/tab-rail-resize.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/git-status.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-auto-collapse.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/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
|
||||
'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 \
|
||||
&& 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
|
||||
# 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
|
||||
|
||||
+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.
|
||||
|
||||
**2. `stop` fires only for `claude` (and `deepseek`) sessions, `blocked` also for
|
||||
`codex`.** Both come from Claude Code hooks, and no other mode installs them: `shell`
|
||||
runs no agent, and the external CLIs render their own TUIs and post no hooks
|
||||
(`deepseek`'s status bridge reports the same two, see its own docs). The one exception
|
||||
is `blocked` on `codex`: Codeman reads codex's approval, question and trust dialogs off
|
||||
its screen and raises `blocked` for them (see [Signals](#signals)). For every other mode
|
||||
`codex` and a local `omp` session.** Both come from Claude Code hooks, and no other mode
|
||||
installs them: `shell` runs no agent, and the external CLIs render their own TUIs and post
|
||||
no hooks (`deepseek`'s status bridge reports the same two, see its own docs). There are two
|
||||
exceptions, both for `blocked` alone. On `codex`, Codeman reads codex's approval, question
|
||||
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
|
||||
`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
|
||||
@@ -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) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
`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
|
||||
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
|
||||
|
||||
`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.
|
||||
|
||||
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
|
||||
`POST /api/v1/sessions/:id/html-view` accept an ABSOLUTE path inside it, while a relative path
|
||||
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download`,
|
||||
`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
|
||||
(`.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:
|
||||
@@ -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
|
||||
`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`)
|
||||
|
||||
`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,
|
||||
AskUserQuestion questions, idle prompts). Answerable items come from Claude-mode
|
||||
sessions only; DeepSeek Harness (its status bridge) and Codex (dialogs read off its
|
||||
screen) open **alert-only** items with `answerInTerminal: true`. Items are
|
||||
sessions only; DeepSeek Harness (its status bridge), Codex (dialogs read off its
|
||||
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
|
||||
codex dialog still on screen is read again after the pane re-attaches).
|
||||
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
|
||||
|
||||
`~/.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 |
|
||||
| ------ | ----------------------------- | ------------------------------------ | ----- |
|
||||
| `GET` | `/api/settings` | none | The stored settings object. Never writes. For a non-admin in multi-user mode, without `voiceSettings.apiKey`, `caseOrder` and `lastUsedCase`. |
|
||||
| `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable 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/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. `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/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. |
|
||||
@@ -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).
|
||||
|
||||
`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
|
||||
|
||||
@@ -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. |
|
||||
| `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
|
||||
|
||||
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
|
||||
|
||||
`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.
|
||||
|
||||
@@ -1725,11 +1774,14 @@ Every route is admin only in multi-user mode (`403 FORBIDDEN`, answered before a
|
||||
| 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. |
|
||||
| `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/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). |
|
||||
| `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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
- `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.
|
||||
- `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);
|
||||
- `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);
|
||||
@@ -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`;
|
||||
- `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;
|
||||
- `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).
|
||||
- `catalogUpdated`: ISO date on which the catalogue's rows were last checked.
|
||||
- `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`.
|
||||
- `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.
|
||||
- `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`.
|
||||
- `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`.
|
||||
- `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.
|
||||
- `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:
|
||||
|
||||
@@ -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) |
|
||||
| `other-port` | `port-in-use` |
|
||||
| `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`.
|
||||
|
||||
@@ -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
|
||||
`{"t":"finalize"}` (ask for the final transcript) and `{"t":"stop"}`. Server
|
||||
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`
|
||||
unavailable (reason in the close reason), `4008` too many concurrent streams.
|
||||
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
|
||||
|
||||
**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)**: `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 → `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 `isAltScreenLine 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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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** (`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
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
@@ -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** (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.
|
||||
- **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).
|
||||
- **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).
|
||||
|
||||
### 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
|
||||
|
||||
**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 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 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.
|
||||
|
||||
**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`.
|
||||
|
||||
**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** (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).
|
||||
|
||||
@@ -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**.
|
||||
|
||||
⚠️ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
⚠️ **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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
⚠️ **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)
|
||||
|
||||
|
||||
+11
-3
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
`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 |
|
||||
| --- | --- |
|
||||
| claude | `.claude/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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -578,6 +578,13 @@ automatically). Results:
|
||||
|
||||
- **Claude, opencode, Pi, Grok, OMP** — verified: a real "hello world" reply
|
||||
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
|
||||
server that DOES answer `/v1/responses` (confirmed live: a plain,
|
||||
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
|
||||
`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.
|
||||
`antigravity`, `grok` and `omp` declare no `npmPackage` at all, so they never enter the shared
|
||||
Six CLIs keep hand-written layers, for two different reasons that are easy to conflate.
|
||||
`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
|
||||
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
|
||||
@@ -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. |
|
||||
| `antigravity` | Not on npm — Google ships a standalone binary (~190MB, the largest layer). |
|
||||
| `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
|
||||
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
|
||||
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
|
||||
@@ -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).
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
`900x700`. There are **no `.file-preview-*` rules in `mobile.css` at all**.
|
||||
|
||||
**Reachability on phones.** The header File Viewer button is hidden below 430px
|
||||
(`mobile.css:482`, locked by `KNOWN_PHONE_HIDDEN` in `test/mobile-header-buttons-policy.test.ts`), so on a
|
||||
**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`); 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:
|
||||
|
||||
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
|
||||
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;
|
||||
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
|
||||
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`).
|
||||
@@ -363,7 +366,8 @@ Route cases to assert explicitly:
|
||||
4. symlink pointing inside is written through to the target
|
||||
5. non-allowlisted extension gives 400
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
+184
-38
@@ -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
|
||||
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
|
||||
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
|
||||
([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
|
||||
|
||||
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
|
||||
the catalogue answers today, before the runtime measured anything (the 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):
|
||||
fits this Mac's memory at a context a coding agent can work with, and under it every other
|
||||
model of the catalogue that fits (ten models today: a 16 GB Mac sees one of them, a 512 GB
|
||||
Mac all ten). What the catalogue answers today, before the runtime measured anything (the
|
||||
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 |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 8 GB | 2.1 GB | Nothing. The smallest model a coding agent can work with needs 10.2 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| Memory | Budget | What it gets |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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 | 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. 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
|
||||
"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
|
||||
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
|
||||
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.
|
||||
@@ -77,8 +80,10 @@ Where every figure comes from:
|
||||
| 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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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.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 +91,16 @@ 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 |
|
||||
|
||||
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
|
||||
calculation. For a llama.cpp build the check after the download catches a load that was
|
||||
2026-11-08 (the Ornith 1.0 and Muse Glimmer rows on 2026-11-10). Only one row was ever run on
|
||||
a Mac, so every other answer in the table above is a calculation. 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
|
||||
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
|
||||
requests, in which MLX may keep up to 8 GiB more. The catalogue header lists the models left out on
|
||||
purpose (no evidence of working tool calls, a restrictive licence, a known bug) and why.
|
||||
requests, in which MLX may keep up to 8 GiB more. 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
|
||||
linear-attention or sliding-window layers, from its `config.json`) in the table of
|
||||
@@ -103,7 +111,9 @@ for a row it does not know. Its answer for each memory size is then pinned in
|
||||
## What an install does, in order
|
||||
|
||||
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.
|
||||
|
||||
| Step | What it does |
|
||||
@@ -206,6 +216,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
|
||||
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
|
||||
|
||||
Two writes outside the local model folder, both undone by Remove:
|
||||
@@ -240,14 +310,17 @@ Switching the setting on also starts that five-minute sweep for every other save
|
||||
|
||||
## 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
|
||||
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
|
||||
ours);
|
||||
- the digest the runtime reports for the installed model is the one stored at install time;
|
||||
- the model asked for is the one that was installed and checked.
|
||||
- the digest the runtime reports for the model in use is the one stored when it was 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
|
||||
with the model, a model applied to a running session (which restarts its agent), the attach
|
||||
@@ -266,29 +339,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
|
||||
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
|
||||
is installed or why nothing can run; `spawn_worker <name> local` starts the runtime when it was
|
||||
stopped (as Start does), then starts the installed coding agent on `local-ollama` through the same
|
||||
is installed or why nothing can run, with the coding agents the model can be run with and the
|
||||
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
|
||||
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
|
||||
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** 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
|
||||
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
|
||||
agent stays. While a live session uses the model, Remove answers with the session names
|
||||
instead.
|
||||
runtime and its private home. The downloaded models (all of them) are deleted only when
|
||||
asked. The coding agent stays. While a live session uses the model, Remove answers with
|
||||
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
|
||||
|
||||
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/
|
||||
runtime/ollama-0.40.15.tgz.partial the runtime archive while it downloads
|
||||
runtime/ollama-0.40.15/ the unpacked runtime (about 579 MB)
|
||||
@@ -313,9 +443,12 @@ in App Settings.
|
||||
| ------------ | ------------------------------------------------------------- | ---------------- | -------------------------------------- |
|
||||
| `idle` | stays idle | refused | install |
|
||||
| `installing` | becomes `paused` at the same step | becomes `paused` | cancel |
|
||||
| `paused` | stays paused | refused | resume, install, remove |
|
||||
| `failed` | stays failed, same error | refused | resume, install, remove |
|
||||
| `installed` | stays installed; the runtime is started unless it was stopped | refused | start a session, stop or start, remove |
|
||||
| `paused` | stays paused | refused | resume, install, use, delete a model, 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, 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
|
||||
without a click. Cancel interrupts the step in flight (the download, the pull, the request,
|
||||
@@ -355,8 +488,21 @@ or `python3`.
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). **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 |
|
||||
| `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
|
||||
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
|
||||
`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`,
|
||||
@@ -163,13 +176,13 @@ remote user's home, so resolving locally would pin a stranger's id. See
|
||||
|
||||
## 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
|
||||
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.
|
||||
Without it an omp session that had started a turn never left `busy`. If omp ever
|
||||
ships a hooks system, a Codeman hook POSTing to `/api/hook-event` would still be
|
||||
the highest-value follow-up.
|
||||
Without it an omp session that had started a turn never left `busy`. Reporting
|
||||
end-of-turn from the same module would be the follow-up that earns omp `stop`.
|
||||
- **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
|
||||
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
|
||||
|
||||
@@ -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). |
|
||||
| `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. |
|
||||
| `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()`. |
|
||||
|
||||
Persistence is two flat JSON arrays in the instance data dir:
|
||||
|
||||
@@ -88,14 +88,18 @@ decision.
|
||||
|
||||
### Experimental local model routes
|
||||
|
||||
`GET /api/v1/local-model`, `POST /api/v1/local-model/install`, `/cancel`, `/resume`
|
||||
and `/runtime`, and `DELETE /api/v1/local-model` (and their `/api` aliases) are
|
||||
`GET /api/v1/local-model`, `POST /api/v1/local-model/install`, `/use`, `/cancel`,
|
||||
`/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
|
||||
(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
|
||||
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
|
||||
explicit request that carries the size the person confirmed. All of them are admin
|
||||
|
||||
+37
-7
@@ -1,9 +1,9 @@
|
||||
# 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.
|
||||
|
||||
## The eleven modes
|
||||
## The twelve modes
|
||||
|
||||
| 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) |
|
||||
| **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) |
|
||||
| **jcode** | `jcode` | [jcode.sh](https://jcode.sh) |
|
||||
| **Terminal / Shell** | your `$SHELL` | Already installed. |
|
||||
|
||||
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.
|
||||
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,
|
||||
`grok` has npm squatters, and Debian ships an unrelated `dsh` (dancer's shell). Each has a
|
||||
status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`, `/api/copilot/status`) that reports
|
||||
`grok` has npm squatters, an unrelated npm package is also called `jcode`, and Debian ships an
|
||||
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
|
||||
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 |
|
||||
| Cron jobs | 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 |
|
||||
| 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 |
|
||||
@@ -352,6 +354,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).
|
||||
|
||||
### 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
|
||||
|
||||
A plain shell in a tmux session. No agent, no hooks, no idle detection.
|
||||
@@ -379,6 +408,7 @@ respawns. Which variables are accepted depends on the mode:
|
||||
| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
|
||||
| OMP | `OMP_*` |
|
||||
| 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
|
||||
is one global list, so widening it for one CLI widens it for all of them. In multi-user mode
|
||||
@@ -394,7 +424,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
|
||||
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
|
||||
hook-driven features (Codex dialogs still raise the red alert and an alert-only inbox card,
|
||||
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
|
||||
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.
|
||||
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
|
||||
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
|
||||
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
|
||||
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`
|
||||
`lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json`
|
||||
`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
|
||||
the wiki.
|
||||
- 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.
|
||||
|
||||
## Conduct
|
||||
|
||||
+16
-16
@@ -92,10 +92,10 @@ A session carries state the case does not:
|
||||
## Run mode
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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.
|
||||
@@ -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). |
|
||||
| **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
|
||||
mode. All eleven run modes work in all three locations. A case is docker-backed or
|
||||
This matters because it is a common source of confusion: Docker is **not** a thirteenth run
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
| Term | Means |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Case** | Named working directory. |
|
||||
| **Session** | One CLI in one tmux session. |
|
||||
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. |
|
||||
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
|
||||
| **Ralph loop** | An autonomous single-session task loop. |
|
||||
| **Orchestrator** | A phased plan driven across multiple agents. |
|
||||
| **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. |
|
||||
| **Instance** | One Codeman server with its own data directory and tmux socket. |
|
||||
| Term | Means |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| **Case** | Named working directory. |
|
||||
| **Session** | One CLI in one tmux session. |
|
||||
| **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. |
|
||||
| **Ralph loop** | An autonomous single-session task loop. |
|
||||
| **Orchestrator** | A phased plan driven across multiple agents. |
|
||||
| **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. |
|
||||
| **Instance** | One Codeman server with its own data directory and tmux socket. |
|
||||
|
||||
## Read next
|
||||
|
||||
- [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.
|
||||
- [`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
|
||||
|
||||
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
|
||||
on this Mac).
|
||||
@@ -234,6 +236,15 @@ Good to know:
|
||||
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
|
||||
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
|
||||
coding agent stays installed.
|
||||
- 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
|
||||
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).
|
||||
|
||||
## 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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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?
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
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
|
||||
subscription limit resets, runs jobs on a schedule, and shows every background subagent
|
||||
live.
|
||||
@@ -33,7 +33,7 @@ codeman web # then open http://localhost:3000
|
||||
|
||||
**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.
|
||||
- [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.
|
||||
@@ -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? |
|
||||
| [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? |
|
||||
| [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? |
|
||||
| [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? |
|
||||
@@ -129,7 +130,7 @@ codeman web # then open http://localhost:3000
|
||||
| OS | macOS or Linux. Windows works through WSL2. |
|
||||
| Node.js | 22 or newer. |
|
||||
| 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). |
|
||||
|
||||
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. |
|
||||
| **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
|
||||
|
||||
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. |
|
||||
| **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. |
|
||||
| **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
|
||||
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.
|
||||
|
||||
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 are off
|
||||
until you choose, and the menu only exists on such a Mac.
|
||||
**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 three times when one needs permission.
|
||||
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
|
||||
|
||||
@@ -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. |
|
||||
| **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. |
|
||||
| **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
|
||||
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
|
||||
|
||||
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.
|
||||
Persian, Arabic and Hebrew answers read right to left there.
|
||||
last answer as scrollable text instead, with a **More** button for additional context and a
|
||||
**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
|
||||
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
|
||||
|
||||
- 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 desktop home tab rail, which needs a wide window.
|
||||
- Lineage lines, which are a desktop overlay.
|
||||
|
||||
@@ -70,6 +70,7 @@ one:
|
||||
| **Grok Build** | xAI's CLI. |
|
||||
| **DeepSeek Harness** | Needs a terminal profile; the menu offers to install one. |
|
||||
| **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. |
|
||||
|
||||
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
|
||||
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).
|
||||
|
||||
## Why bother
|
||||
|
||||
@@ -247,8 +247,9 @@ Discord group for the two-way bot (token, IDs, thread and ping options; see
|
||||
### Voice
|
||||
|
||||
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
|
||||
server's Claude login, with its live credential status. See
|
||||
recognition, **Stop after silence** (provider default, 3, 6, 15 or 30 seconds, or Manual),
|
||||
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).
|
||||
|
||||
### Shortcuts
|
||||
@@ -258,7 +259,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
||||
### 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
|
||||
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
|
||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||
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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -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
|
||||
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
|
||||
answers read right to left here, which the terminal cannot do, while code and the file paths
|
||||
it links stay left to right.
|
||||
terminal viewport is painful. **More** loads additional context. **Copy Markdown** copies the
|
||||
newest answer's raw Markdown, every part of its turn, so headings, lists and code fences paste
|
||||
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
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ until you get home.
|
||||
|
||||
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
|
||||
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
|
||||
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,
|
||||
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:
|
||||
|
||||
| Kind | Behaviour |
|
||||
@@ -69,6 +77,11 @@ convenient:
|
||||
- **`.git/` is denied outright.** Hooks are executable code, and a corrupted index looks
|
||||
unrecoverable to someone who wanted to fix a typo.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- [Tile Grid](Tile-Grid)
|
||||
- [Agent CLIs](Agent-CLIs)
|
||||
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
||||
- [Local Models](Local-Models)
|
||||
- [Working With Files](Working-With-Files)
|
||||
- [Git Worktrees](Git-Worktrees)
|
||||
- [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
|
||||
# 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.
|
||||
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp' 'copilot')
|
||||
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'GitHub Copilot')
|
||||
CLI_ENABLED=(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_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_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_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_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'copilot')
|
||||
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9)
|
||||
CLI_BIN_LEN=(1 0 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_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47)
|
||||
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5)
|
||||
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' 'jcode')
|
||||
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 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' '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' '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' 'curl -fsSL https://jcode.sh/install | bash')
|
||||
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 10)
|
||||
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" "$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 52)
|
||||
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5 3)
|
||||
# <<< END GENERATED CLI CATALOGUE
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"mcpName": "io.github.Ark0N/codeman",
|
||||
"type": "module",
|
||||
|
||||
@@ -50,6 +50,7 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSOperation",
|
||||
"NSProcessInfo",
|
||||
"NSRange",
|
||||
"NSSet",
|
||||
"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). |
|
||||
| **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. |
|
||||
| **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. |
|
||||
|
||||
## 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
|
||||
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
|
||||
--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
|
||||
@@ -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
|
||||
WebKit has a cost here: after a server restart, each request that arrives
|
||||
without a cookie gets a 401 before the
|
||||
login goes out, and those count until one succeeds. Many windows reconnecting
|
||||
at once can push a request past 10, and that request then fails with a 429
|
||||
instead of being answered. A refusal that lands while you are in another app
|
||||
does not bring Codeman forward: the connect screen loads where the window is,
|
||||
and the Dock icon bounces.
|
||||
login goes out, and those count until on**The logo moves on its own hardware fades, from a child process, through CoreBrightness.**
|
||||
Apple documents one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
|
||||
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`, an on/off setting that
|
||||
`corebrightnessd` keeps in root's preferences. A process of the logged-in user can reach the
|
||||
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
|
||||
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
|
||||
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
|
||||
for every blink would need a root helper and an administrator password. The same
|
||||
setting is also a property of `BrightnessSystemClient` in the private
|
||||
CoreBrightness framework, and a process of the logged-in user can set it there,
|
||||
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
|
||||
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
|
||||
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
|
||||
keeps its value while the logo is dark.
|
||||
The daemon decides the rest. Writing the direction the logo already heads in is ignored, and
|
||||
the state cannot be read back, so the child keeps track of it and, when it does not know,
|
||||
first turns the logo round with a fade too slow to see. The keyboard backlight follows
|
||||
`DisplayLogicalState` too, so after every Blank the keyboards alone are told Unblank
|
||||
(`setProperty:withKey:keyboardID:`), only while the built-in display is awake
|
||||
(`CGDisplayIsAsleep`); when it sleeps they are told Blank, and Unblank when it wakes. A rise
|
||||
from exactly dark waits for the display to show a frame, up to half a second with nothing
|
||||
changing on screen. So the short dark beats (Alarm's, Heartbeat's, Wink's, Fast's) turn round
|
||||
while their fall is still under way and never quite reach dark, and a long one (Calm's,
|
||||
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
|
||||
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 any style is chosen 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 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-blink`, next to `desktop.json`) covers what the child cannot survive itself:
|
||||
With the lid closed the daemon tells every module so (`ClamshellUpdate` 1), and the logo's
|
||||
target is then 0 whatever `DisplayLogicalState` says. A client's `ClamshellUpdate` reaches the
|
||||
same modules, and LogoLight measured on the real MacBook what follows from that, first with an
|
||||
external display and, for its 1.3.1, on a Mac kept awake with none:
|
||||
- Kept awake with the lid closed and no other display, the built-in display stays online and
|
||||
`CGDisplayIsAsleep` says 0. So the lid sensor (`AppleClamshellState` on `IOPMrootDomain`)
|
||||
decides that the logo is out of sight, not the display list.
|
||||
- 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
|
||||
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
|
||||
@@ -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.
|
||||
*Check again* asks again.
|
||||
- **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;
|
||||
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
|
||||
from about once every two seconds (*Slow*, *Wink*) to about three times a
|
||||
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
|
||||
setting is on or off, so these styles are rhythms of hard changes. After a
|
||||
blink the key stays there rather than being absent as on a fresh Mac. That
|
||||
reads the same to macOS; `sudo defaults delete com.apple.CoreBrightness
|
||||
LEBacklightMode` removes it. After a power cut in a
|
||||
dark phase the logo stays dark until the app next starts. `api/sessions` does
|
||||
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
|
||||
keep-alive turn blinks too.
|
||||
on/off setting, not the fades the app uses, so a macOS update can take them
|
||||
away. The menu disappears if `LEBacklightMode` goes; if only the fade keys
|
||||
change, the logo stops moving. A long dark beat (*Calm*, *Slow*, the rest in
|
||||
*Breathe*) can be up to half a second longer than its style says, while the
|
||||
display waits for a frame before the logo rises. Every Blank dims the
|
||||
keyboard backlight a little before it is told to stay lit. A child killed
|
||||
outright (SIGKILL) leaves the logo where it was, perhaps dark, until the
|
||||
display next sleeps and wakes or the app next starts. Another app that drives
|
||||
the logo the same way and is not LogoLight is not recognised, and the two
|
||||
fight. The handover with a LogoLight that steps aside was checked against a
|
||||
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`
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
|
||||
//! never go here; they live in the Keychain (see `credentials`).
|
||||
|
||||
use crate::keep_awake;
|
||||
use crate::logo::BlinkStyles;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
@@ -44,11 +45,16 @@ pub struct Config {
|
||||
pub auto_connect: bool,
|
||||
/// Always open in full screen (otherwise the last state is restored).
|
||||
pub start_fullscreen: bool,
|
||||
/// What the lit Apple logo does for each state of the agents (logo.rs). The
|
||||
/// menu only shows on a Mac that has one. A value this version does not know
|
||||
/// reads as off: a bad style must not make the whole file unreadable.
|
||||
/// Whether the lit Apple logo shows what the agents are doing, and its style
|
||||
/// for each state (logo.rs). The menu only shows on a Mac that has one. A
|
||||
/// value this version does not know reads as off: a bad style must not make
|
||||
/// the whole file unreadable.
|
||||
#[serde(deserialize_with = "lenient_blink")]
|
||||
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
|
||||
/// make the whole file unreadable, which would cost the server list.
|
||||
#[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)?))
|
||||
}
|
||||
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
@@ -71,6 +81,7 @@ impl Default for Config {
|
||||
auto_connect: true,
|
||||
start_fullscreen: false,
|
||||
logo_blink: BlinkStyles::default(),
|
||||
keep_awake: keep_awake::Settings::default(),
|
||||
window: None,
|
||||
}
|
||||
}
|
||||
@@ -415,9 +426,12 @@ mod tests {
|
||||
use crate::logo::BlinkStyle;
|
||||
let mut c = Config::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();
|
||||
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);
|
||||
for bad in [
|
||||
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]
|
||||
fn the_style_a_mac_saved_before_there_were_states_is_kept() {
|
||||
use crate::logo::BlinkStyle;
|
||||
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
|
||||
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
|
||||
assert!(c.logo_blink.enabled);
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
mod imp {
|
||||
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)> {
|
||||
// NSWindow is main-thread only.
|
||||
MainThreadMarker::new()?;
|
||||
|
||||
+249
-24
@@ -27,6 +27,10 @@ mod assets;
|
||||
mod config;
|
||||
mod credentials;
|
||||
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 login;
|
||||
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 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_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.
|
||||
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.";
|
||||
@@ -182,6 +187,17 @@ enum UserEvent {
|
||||
/// install opened in Terminal, the install command copied): the message
|
||||
/// goes to the page if it is still the one choosing a server.
|
||||
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 {
|
||||
@@ -318,6 +334,10 @@ struct App {
|
||||
fullscreen_retry: Option<(Instant, u8)>,
|
||||
/// The child that watches the connected server and blinks the logo.
|
||||
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() {
|
||||
@@ -325,6 +345,10 @@ fn main() {
|
||||
if std::env::args().nth(1).as_deref() == Some(logo::BLINK_ARG) {
|
||||
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 proxy = event_loop.create_proxy();
|
||||
@@ -355,6 +379,10 @@ fn main() {
|
||||
let config_path = config_path.filter(|_| can_save);
|
||||
let notice = (!can_save).then(|| SETTINGS_UNREADABLE.to_string());
|
||||
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 phase = Rc::new(RefCell::new(Phase::Connect));
|
||||
// Started once: the probe workers and the event loop share this queue,
|
||||
@@ -370,6 +398,7 @@ fn main() {
|
||||
let mut windows = HashMap::new();
|
||||
windows.insert(main_id, AppWindow::new(webview, window, Kind::Main));
|
||||
|
||||
let keep_awake_proxy = proxy.clone();
|
||||
let mut app = App {
|
||||
proxy,
|
||||
config,
|
||||
@@ -397,8 +426,19 @@ fn main() {
|
||||
add_agent_on_load: false,
|
||||
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -703,6 +743,18 @@ fn build_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 {
|
||||
fn handle(
|
||||
&mut self,
|
||||
@@ -719,17 +771,20 @@ impl App {
|
||||
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),
|
||||
// A click on the Dock icon while no window is visible, which is
|
||||
// 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 {
|
||||
has_visible_windows: false,
|
||||
..
|
||||
} => self.show_main_window(),
|
||||
Event::MainEventsCleared => self.on_events_cleared(control_flow),
|
||||
Event::LoopDestroyed => {
|
||||
self.keep_awake.shutdown();
|
||||
self.record_main_frame();
|
||||
self.save_config();
|
||||
}
|
||||
@@ -790,17 +845,34 @@ impl App {
|
||||
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 {
|
||||
WindowEvent::CloseRequested if id != self.main_id => self.remove_window(id),
|
||||
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
|
||||
// sessions keep running, so nothing is lost.
|
||||
self.record_main_frame();
|
||||
self.save_config();
|
||||
self.keep_awake.shutdown();
|
||||
*control_flow = ControlFlow::Exit;
|
||||
} else {
|
||||
self.remove_window(id);
|
||||
}
|
||||
}
|
||||
WindowEvent::Moved(_) | WindowEvent::Resized(_) if id == self.main_id => self.record_main_frame(),
|
||||
@@ -821,6 +893,7 @@ impl App {
|
||||
self.to_connect_page(msg);
|
||||
}
|
||||
}
|
||||
UserEvent::LogoLidStatus(line) => self.show_lid_status(line),
|
||||
UserEvent::ServerStartDone {
|
||||
generation,
|
||||
url,
|
||||
@@ -875,6 +948,7 @@ impl App {
|
||||
*self.phase.borrow_mut() = next;
|
||||
}
|
||||
self.sync_logo_watch();
|
||||
self.sync_keep_awake();
|
||||
}
|
||||
if let Some(w) = self.windows.get_mut(&window) {
|
||||
w.committed = Url::parse(&url).ok();
|
||||
@@ -937,6 +1011,14 @@ impl App {
|
||||
}
|
||||
UserEvent::AuthRejected => self.recheck_login(),
|
||||
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) {
|
||||
let styles = self.config.logo_blink;
|
||||
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
|
||||
.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) {
|
||||
// A click flips the item it landed on, the chosen one included, so all
|
||||
// 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();
|
||||
let was_active = self.config.logo_blink.active();
|
||||
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.sync_logo_watch();
|
||||
// Only from off: with a style running, a dark logo may be our own.
|
||||
if was_off && style != BlinkStyle::Off && logo::switched_off() {
|
||||
if let Some(main) = self.windows.get(&self.main_id) {
|
||||
let _ = main
|
||||
.webview
|
||||
.evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning"));
|
||||
// Only from off: with a blink running, a dark logo may be our own.
|
||||
if was_active || !styles.active() {
|
||||
return;
|
||||
}
|
||||
let warning = if logo::switched_off() {
|
||||
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) {
|
||||
@@ -1066,10 +1283,18 @@ impl App {
|
||||
self.choose_logo_blink(signal, style);
|
||||
return;
|
||||
}
|
||||
if let Some(mode) = keep_awake::Mode::from_menu_id(id) {
|
||||
self.choose_keep_awake_mode(mode);
|
||||
return;
|
||||
}
|
||||
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::ADD_AGENT => self.add_agent(),
|
||||
menu_id::RELOAD => self.reload_focused(),
|
||||
menu_id::LOGO_BLINK => self.toggle_logo_blink(),
|
||||
menu_id::START_FULLSCREEN => {
|
||||
let value = self.menu.start_fullscreen.is_checked();
|
||||
self.config.start_fullscreen = value;
|
||||
|
||||
@@ -4,6 +4,7 @@ use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::keep_awake::{self, Mode};
|
||||
use crate::logo::{self, BlinkStyle, Signal};
|
||||
use crate::{APP_NAME, VERSION};
|
||||
|
||||
@@ -16,6 +17,7 @@ pub mod menu_id {
|
||||
pub const ADD_AGENT: &str = "add-agent";
|
||||
pub const RELOAD: &str = "reload";
|
||||
pub const START_FULLSCREEN: &str = "start-fullscreen";
|
||||
pub const LOGO_BLINK: &str = "logo-blink";
|
||||
pub const DEVTOOLS: &str = "devtools";
|
||||
pub const HELP_DOCS: &str = "help-docs";
|
||||
pub const HELP_GITHUB: &str = "help-github";
|
||||
@@ -26,9 +28,96 @@ pub struct AppMenu {
|
||||
pub window_menu: Submenu,
|
||||
pub help_menu: Submenu,
|
||||
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.
|
||||
pub logo_switch: Option<CheckMenuItem>,
|
||||
/// One item per state and style, empty without the switch.
|
||||
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> {
|
||||
@@ -67,6 +156,15 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
&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.
|
||||
let edit = Submenu::with_items(
|
||||
"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_switch = None;
|
||||
let mut logo_lid = None;
|
||||
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 {
|
||||
let group = Submenu::new(signal.label(), true);
|
||||
for style in BlinkStyle::ALL {
|
||||
@@ -128,7 +236,12 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
submenu.append(&group)?;
|
||||
}
|
||||
// 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(
|
||||
"Window",
|
||||
@@ -156,6 +269,9 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||
window_menu,
|
||||
help_menu,
|
||||
start_fullscreen,
|
||||
logo_switch,
|
||||
logo_styles,
|
||||
keep_awake,
|
||||
logo_lid,
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
|
||||
"version": "1.50.0",
|
||||
"version": "1.51.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -107,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.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
|
||||
@@ -135,8 +135,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
||||
mkdir -p "$(dirname "$PRE")"
|
||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.50.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) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.51.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- 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
|
||||
# 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).
|
||||
@@ -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.
|
||||
_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 ----
|
||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||
# "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
|
||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||
# {"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
|
||||
# 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
|
||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||
# 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));
|
||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||
| {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"}
|
||||
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
|
||||
# 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))
|
||||
then {spawnable:true} + $base
|
||||
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
|
||||
# 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.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
||||
# makes. A stopped runtime is started first. Any answer 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.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
|
||||
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
|
||||
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
|
||||
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
|
||||
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
|
||||
# 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
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
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
|
||||
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")
|
||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
||||
[ "$mode" = opencode ] && mark='┃'
|
||||
# Readiness is a token the agent's composer draws once it takes input. The server names
|
||||
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
|
||||
# For a server that sends none, the two this skill measured itself:
|
||||
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
|
||||
# at the mark was submitted every time).
|
||||
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
|
||||
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
|
||||
# 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}
|
||||
[ "$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
|
||||
@@ -384,11 +439,16 @@ spawn_worker() {
|
||||
# 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,
|
||||
# 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' \
|
||||
-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}
|
||||
+ (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 == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} 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.
|
||||
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
|
||||
fi
|
||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
||||
# why sendwait refuses it (400) and sendmark drives it.
|
||||
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
|
||||
# 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" = 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; }
|
||||
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
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
@@ -425,6 +492,10 @@ spawn_worker() {
|
||||
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
|
||||
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
|
||||
fi
|
||||
[ "$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
|
||||
# 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
|
||||
# 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.
|
||||
# ⚠️ 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
|
||||
@@ -596,7 +667,12 @@ sendmark() {
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -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
|
||||
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; }
|
||||
@@ -683,10 +759,10 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.50.0
|
||||
CODEMAN_PREAMBLE=1.51.0
|
||||
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
|
||||
@@ -737,7 +813,7 @@ and no per-call body to hand-build.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.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
|
||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||
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
|
||||
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`,
|
||||
`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)).
|
||||
|
||||
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
|
||||
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
|
||||
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
|
||||
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
|
||||
other mode: `spawn_workers plan review:deepseek summ:local`.
|
||||
on the installed model, run by its default coding agent (OpenCode on the Mac it was tested
|
||||
on), and from then on it is the same verbs. It mixes into one fleet like any other mode:
|
||||
`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:
|
||||
|
||||
@@ -866,9 +959,9 @@ Reach for it when:
|
||||
|
||||
```bash
|
||||
. "${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; }
|
||||
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
|
||||
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
|
||||
[ "${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, agents, endpoint; or the reason there is none
|
||||
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)
|
||||
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=$?
|
||||
@@ -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
|
||||
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`,
|
||||
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
|
||||
instead of guessing.
|
||||
`install-unfinished`, `no-agent`, `blocked`, `admin-only`, `unsupported`), so tell the
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
loads the model first. A local worker run by claude spends minutes on its first answer
|
||||
(its fixed prompt is about 36,000 tokens).
|
||||
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
|
||||
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
|
||||
has hooks and `sendwait` works as for any claude worker.
|
||||
- **Markers, not hooks.** OpenCode and Pi report no end of turn, so `sendwait` answers 400
|
||||
for those workers; `sendmark` is the way through. A claude worker has hooks, so
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
# 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).
|
||||
@@ -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.
|
||||
_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 ----
|
||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||
# "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
|
||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||
# {"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
|
||||
# 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
|
||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||
# 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));
|
||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||
| {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"}
|
||||
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
|
||||
# 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))
|
||||
then {spawnable:true} + $base
|
||||
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
|
||||
# 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.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
||||
# makes. A stopped runtime is started first. Any answer 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.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
|
||||
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
|
||||
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
|
||||
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
|
||||
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
|
||||
# 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
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
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
|
||||
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")
|
||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
||||
[ "$mode" = opencode ] && mark='┃'
|
||||
# Readiness is a token the agent's composer draws once it takes input. The server names
|
||||
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
|
||||
# For a server that sends none, the two this skill measured itself:
|
||||
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
|
||||
# at the mark was submitted every time).
|
||||
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
|
||||
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
|
||||
# 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}
|
||||
[ "$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
|
||||
@@ -246,11 +301,16 @@ spawn_worker() {
|
||||
# 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,
|
||||
# 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' \
|
||||
-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}
|
||||
+ (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 == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} 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.
|
||||
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
|
||||
fi
|
||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
||||
# why sendwait refuses it (400) and sendmark drives it.
|
||||
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
|
||||
# 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" = 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; }
|
||||
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
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
@@ -287,6 +354,10 @@ spawn_worker() {
|
||||
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
|
||||
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
|
||||
fi
|
||||
[ "$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
|
||||
# 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
|
||||
# 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.
|
||||
# ⚠️ 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
|
||||
@@ -458,7 +529,12 @@ sendmark() {
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -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
|
||||
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; }
|
||||
@@ -545,4 +621,4 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.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`
|
||||
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
|
||||
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`
|
||||
|
||||
@@ -242,7 +245,7 @@ minutes, never retry the credential.
|
||||
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).
|
||||
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
|
||||
for the same reason claude does (the harness finalizes the assistant message just after
|
||||
it reports `idle`), so poll it the same way.
|
||||
@@ -346,7 +349,7 @@ ESC=$(printf '\033')
|
||||
|
||||
`POST /api/v1/quick-start` body (all optional):
|
||||
`{"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
|
||||
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
|
||||
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/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/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,
|
||||
`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
|
||||
`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, Copilot's and jcode's also carry `.data.version`, because `pi` is a short generic name,
|
||||
`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
|
||||
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.
|
||||
@@ -492,8 +496,8 @@ Quirks that will bite you:
|
||||
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
||||
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
||||
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**
|
||||
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
|
||||
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`. ⚠️ **`shell` is NOT one of those**
|
||||
(`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
|
||||
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
|
||||
@@ -657,7 +661,7 @@ Three bounded long-polls. Shared semantics:
|
||||
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
|
||||
| `working` | session started producing output | every mode |
|
||||
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude`; 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 |
|
||||
|
||||
⚠️ **`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) |
|
||||
| liveness / death check | HTTP `wait?until=exit` |
|
||||
| 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 |
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.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
|
||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
# (.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.)
|
||||
|
||||
# 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
|
||||
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
||||
real end-of-turn signal rather than a guess. On
|
||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`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,
|
||||
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**
|
||||
`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:
|
||||
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 —
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
`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.
|
||||
|
||||
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
|
||||
{"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):
|
||||
|
||||
| `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-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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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":…,
|
||||
"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
|
||||
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
|
||||
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
|
||||
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
|
||||
and no `confirmed*` flag is ever sent.
|
||||
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
|
||||
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
|
||||
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
|
||||
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
|
||||
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
|
||||
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.
|
||||
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. The token is the agent's
|
||||
`readyMark` from `local_model` when the server sends one, else the one measured for this
|
||||
skill, and `LOCAL_READY_MARK` overrides both. All of it was measured on Linux through a
|
||||
custom model launch, none of it on a Mac yet:
|
||||
|
||||
| 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
|
||||
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
|
||||
|
||||
+147
-42
@@ -107,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway:
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.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
|
||||
@@ -135,8 +135,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
||||
mkdir -p "$(dirname "$PRE")"
|
||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.50.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) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.51.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- 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
|
||||
# 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).
|
||||
@@ -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.
|
||||
_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 ----
|
||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||
# "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
|
||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||
# {"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
|
||||
# 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
|
||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||
# 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));
|
||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||
| {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"}
|
||||
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
|
||||
# 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))
|
||||
then {spawnable:true} + $base
|
||||
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
|
||||
# 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.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
||||
# makes. A stopped runtime is started first. Any answer 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.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
|
||||
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
|
||||
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
|
||||
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
|
||||
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
|
||||
# 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
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
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
|
||||
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")
|
||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
||||
[ "$mode" = opencode ] && mark='┃'
|
||||
# Readiness is a token the agent's composer draws once it takes input. The server names
|
||||
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
|
||||
# For a server that sends none, the two this skill measured itself:
|
||||
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
|
||||
# at the mark was submitted every time).
|
||||
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
|
||||
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
|
||||
# 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}
|
||||
[ "$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
|
||||
@@ -384,11 +439,16 @@ spawn_worker() {
|
||||
# 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,
|
||||
# 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' \
|
||||
-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}
|
||||
+ (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 == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} 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.
|
||||
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
|
||||
fi
|
||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
||||
# why sendwait refuses it (400) and sendmark drives it.
|
||||
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
|
||||
# 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" = 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; }
|
||||
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
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
@@ -425,6 +492,10 @@ spawn_worker() {
|
||||
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
|
||||
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
|
||||
fi
|
||||
[ "$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
|
||||
# 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
|
||||
# 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.
|
||||
# ⚠️ 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
|
||||
@@ -596,7 +667,12 @@ sendmark() {
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -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
|
||||
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; }
|
||||
@@ -683,10 +759,10 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.50.0
|
||||
CODEMAN_PREAMBLE=1.51.0
|
||||
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
|
||||
@@ -737,7 +813,7 @@ and no per-call body to hand-build.
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.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
|
||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||
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
|
||||
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`,
|
||||
`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)).
|
||||
|
||||
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
|
||||
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
|
||||
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
|
||||
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
|
||||
other mode: `spawn_workers plan review:deepseek summ:local`.
|
||||
on the installed model, run by its default coding agent (OpenCode on the Mac it was tested
|
||||
on), and from then on it is the same verbs. It mixes into one fleet like any other mode:
|
||||
`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:
|
||||
|
||||
@@ -866,9 +959,9 @@ Reach for it when:
|
||||
|
||||
```bash
|
||||
. "${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; }
|
||||
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
|
||||
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
|
||||
[ "${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, agents, endpoint; or the reason there is none
|
||||
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)
|
||||
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=$?
|
||||
@@ -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
|
||||
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`,
|
||||
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
|
||||
instead of guessing.
|
||||
`install-unfinished`, `no-agent`, `blocked`, `admin-only`, `unsupported`), so tell the
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
loads the model first. A local worker run by claude spends minutes on its first answer
|
||||
(its fixed prompt is about 36,000 tokens).
|
||||
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
|
||||
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
|
||||
has hooks and `sendwait` works as for any claude worker.
|
||||
- **Markers, not hooks.** OpenCode and Pi report no end of turn, so `sendwait` answers 400
|
||||
for those workers; `sendmark` is the way through. A claude worker has hooks, so
|
||||
`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
|
||||
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
|
||||
|
||||
+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
|
||||
# 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).
|
||||
@@ -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.
|
||||
_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 ----
|
||||
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
|
||||
# "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
|
||||
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
|
||||
# {"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
|
||||
# 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
|
||||
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
|
||||
# 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));
|
||||
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
|
||||
| {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"}
|
||||
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
|
||||
# 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))
|
||||
then {spawnable:true} + $base
|
||||
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
|
||||
# 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.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
|
||||
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
|
||||
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
|
||||
# makes. A stopped runtime is started first. Any answer 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.
|
||||
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
|
||||
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
|
||||
# picks another one of local_model's `agents`, which must be `ready`. The worker's mode
|
||||
# becomes that agent, and the endpoint and model ride the quick-start as `customModel`: the
|
||||
# launch the window's "Start a session" makes. A stopped runtime is started first. Any answer
|
||||
# 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
|
||||
# it is set Claude Code's advisor tool: a stronger model the worker consults before
|
||||
# committing to an approach, on a recurring error and before declaring the task done.
|
||||
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
|
||||
# advisor that ranks below the worker's model is accepted but never attached by claude.
|
||||
spawn_worker() {
|
||||
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
|
||||
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")
|
||||
# Readiness is a token the agent's composer draws. The registry's composer data never
|
||||
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
|
||||
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
|
||||
# that, and declares none for opencode), but at boot only the composer draws it: measured
|
||||
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
|
||||
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
|
||||
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
|
||||
[ "$mode" = opencode ] && mark='┃'
|
||||
# Readiness is a token the agent's composer draws once it takes input. The server names
|
||||
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
|
||||
# For a server that sends none, the two this skill measured itself:
|
||||
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
|
||||
# at the mark was submitted every time).
|
||||
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
|
||||
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
|
||||
# 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}
|
||||
[ "$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
|
||||
@@ -246,11 +301,16 @@ spawn_worker() {
|
||||
# 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,
|
||||
# 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' \
|
||||
-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}
|
||||
+ (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 == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} 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.
|
||||
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
|
||||
fi
|
||||
if [ -n "$cm" ] && [ "$mode" != claude ]; then
|
||||
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
|
||||
# why sendwait refuses it (400) and sendmark drives it.
|
||||
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
|
||||
# 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" = 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; }
|
||||
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
|
||||
fi
|
||||
if [ "$mode" = deepseek ]; then
|
||||
@@ -287,6 +354,10 @@ spawn_worker() {
|
||||
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
|
||||
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
|
||||
fi
|
||||
[ "$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
|
||||
# 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
|
||||
# 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.
|
||||
# ⚠️ 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
|
||||
@@ -458,7 +529,12 @@ sendmark() {
|
||||
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
|
||||
esac
|
||||
[ -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
|
||||
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; }
|
||||
@@ -545,4 +621,4 @@ last_text() {
|
||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||
# here would fail that match and rewrite this file on every single bootstrap.
|
||||
CODEMAN_PREAMBLE=1.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`
|
||||
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
|
||||
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`
|
||||
|
||||
@@ -242,7 +245,7 @@ minutes, never retry the credential.
|
||||
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).
|
||||
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
|
||||
for the same reason claude does (the harness finalizes the assistant message just after
|
||||
it reports `idle`), so poll it the same way.
|
||||
@@ -346,7 +349,7 @@ ESC=$(printf '\033')
|
||||
|
||||
`POST /api/v1/quick-start` body (all optional):
|
||||
`{"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
|
||||
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
|
||||
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/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/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,
|
||||
`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
|
||||
`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, Copilot's and jcode's also carry `.data.version`, because `pi` is a short generic name,
|
||||
`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
|
||||
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.
|
||||
@@ -492,8 +496,8 @@ Quirks that will bite you:
|
||||
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
||||
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
||||
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**
|
||||
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
|
||||
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`jcode`. ⚠️ **`shell` is NOT one of those**
|
||||
(`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
|
||||
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
|
||||
@@ -657,7 +661,7 @@ Three bounded long-polls. Shared semantics:
|
||||
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
|
||||
| `working` | session started producing output | every mode |
|
||||
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude`; 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 |
|
||||
|
||||
⚠️ **`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) |
|
||||
| liveness / death check | HTTP `wait?until=exit` |
|
||||
| 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 |
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
||||
|
||||
```bash
|
||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.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
|
||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
# (.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.)
|
||||
|
||||
# 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
|
||||
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
||||
real end-of-turn signal rather than a guess. On
|
||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`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,
|
||||
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**
|
||||
`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:
|
||||
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 —
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
`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.
|
||||
|
||||
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
|
||||
{"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):
|
||||
|
||||
| `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-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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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":…,
|
||||
"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
|
||||
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
|
||||
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
|
||||
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
|
||||
and no `confirmed*` flag is ever sent.
|
||||
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
|
||||
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
|
||||
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
|
||||
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
|
||||
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
|
||||
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.
|
||||
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. The token is the agent's
|
||||
`readyMark` from `local_model` when the server sends one, else the one measured for this
|
||||
skill, and `LOCAL_READY_MARK` overrides both. All of it was measured on Linux through a
|
||||
custom model launch, none of it on a Mac yet:
|
||||
|
||||
| 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
|
||||
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
|
||||
|
||||
@@ -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
|
||||
// conversation id after a /clear, and a web tab being opened.
|
||||
'users.json': ['lastLoginAt'],
|
||||
'settings.json': ['lastUsedCase'],
|
||||
'settings.json': ['lastUsedCase', 'lastPickedCase'],
|
||||
'intents.json': ['recentPrompts', 'updatedAt'],
|
||||
'docker-cases.json': ['lastClaudeSessionId'],
|
||||
'webviews.json': ['lastOpenedAt'],
|
||||
|
||||
@@ -126,9 +126,15 @@ export async function loadAttachmentGuardConfig(): Promise<AttachmentGuardConfig
|
||||
*
|
||||
* NOTE: this is intentionally a SUPERSET of `isSensitivePath` so `/api/download`
|
||||
* 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 {
|
||||
if (isSensitivePath(absPath)) return true;
|
||||
export function isBlockedAttachmentPath(
|
||||
absPath: string,
|
||||
blockedTrees: readonly string[],
|
||||
opts: { allowDotenv?: boolean } = {}
|
||||
): boolean {
|
||||
if (isSensitivePath(absPath, opts)) return true;
|
||||
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
|
||||
}
|
||||
|
||||
|
||||
@@ -314,6 +314,8 @@ const capabilitiesSchema = z
|
||||
// Keep the PTY's rows while a phone keyboard is up (inline renderers only).
|
||||
// Optional: absent means the keyboard resizes, the safe default.
|
||||
keyboardHold: z.literal('inline').optional(),
|
||||
// Spare the CLI's own detached daemon (another process group) when a pane is killed.
|
||||
sharedDaemon: z.boolean().optional(),
|
||||
keyboardAccessory: z.enum(['agent', 'shell']),
|
||||
privilegedCommandGate: z.boolean(),
|
||||
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
|
||||
// user-editable, so it can never be a free path (`..`, absolute).
|
||||
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(),
|
||||
// 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
|
||||
|
||||
@@ -1661,9 +1661,11 @@ const DEEPSEEK: CliEntry = {
|
||||
};
|
||||
|
||||
// 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
|
||||
// own config governs and there is deliberately nothing bypass-shaped to clamp. Its only
|
||||
// privileged surface is a pair of ENV keys (see privilegedEnvKeys below).
|
||||
// flags at all — omp reads its model routing, approval mode and user hooks from
|
||||
// `~/.omp/agent`, so the CLI's own config governs and there is deliberately nothing
|
||||
// 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 = {
|
||||
id: 'omp' as CliEntry['id'],
|
||||
label: 'OMP',
|
||||
@@ -1742,6 +1744,19 @@ const OMP: CliEntry = {
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
...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
|
||||
// tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip.
|
||||
altScreen: 'strip-mux-only',
|
||||
@@ -1763,7 +1778,8 @@ const OMP: CliEntry = {
|
||||
promptGlyph: '╰─',
|
||||
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.
|
||||
privilegedParams: [],
|
||||
// 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. */
|
||||
export const STOCK_CLIS: CliEntry[] = [
|
||||
CLAUDE,
|
||||
@@ -2036,4 +2188,5 @@ export const STOCK_CLIS: CliEntry[] = [
|
||||
DEEPSEEK,
|
||||
OMP,
|
||||
COPILOT,
|
||||
JCODE,
|
||||
];
|
||||
@@ -508,8 +508,11 @@ export interface CliCapabilities {
|
||||
* 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:
|
||||
* 'none' — no hook signals, ever (every external CLI, and `shell`).
|
||||
* 'always' — the CLI installs Codeman's hooks (claude).
|
||||
* 'none' — no hook signals, ever (every external CLI, and `shell`). A CLI can
|
||||
* 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
|
||||
* over a generic env-gated contract, and Codeman is that supervisor
|
||||
* (deepseek, via deepseek-status-shim.ts). Definitive rather than
|
||||
@@ -523,6 +526,21 @@ export interface CliCapabilities {
|
||||
* timeout the predicate exists to prevent.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -622,6 +640,19 @@ export interface CliCapabilities {
|
||||
* losing lines, so it resizes as before), and for remote and docker sessions.
|
||||
*/
|
||||
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';
|
||||
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
|
||||
privilegedCommandGate: boolean;
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
*
|
||||
* Design (docs/file-viewer-edit-plan.md):
|
||||
* - ALLOWLIST of text extensions/basenames, not a blocklist — matching the
|
||||
* attachment-guard precedent. `svg` and `env` are deliberately absent: svg is
|
||||
* treated as untrusted on the read side, and `.env` is sensitive-path blocked
|
||||
* anyway; excluding them here keeps a single obvious refusal.
|
||||
* attachment-guard precedent. `svg` and `env` are deliberately absent from
|
||||
* the extension list: svg is treated as untrusted on the read side, and the
|
||||
* list doubles as the attachment list, which must not gain `*.env`. Dotenv
|
||||
* files (`.env`, `.env.*`, `*.env`) are editable by NAME in
|
||||
* isEditableFileName instead: the plain preview already shows them, and the
|
||||
* routes pass `allowDotenv` to the sensitive-path check, which keeps every
|
||||
* other credential pattern.
|
||||
* - The `.git/` subtree is denied outright: `.git/hooks/*` is code execution and
|
||||
* a corrupted index looks unrecoverable to a user who wanted to fix a typo.
|
||||
* - EOL helpers exist because a browser <textarea> normalizes to LF; the server
|
||||
@@ -116,6 +120,10 @@ export const EDITABLE_BASENAMES: ReadonlySet<string> = new Set([
|
||||
export function isEditableFileName(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase();
|
||||
if (EDITABLE_BASENAMES.has(lower)) return true;
|
||||
// Dotenv in every spelling, by name: `.env.local`'s extension is `local`, and
|
||||
// `env` must stay off EDITABLE_EXTENSIONS, which is also the cross-workspace
|
||||
// attachment list (TEXT_ATTACHMENT_EXTENSIONS) that never accepted `*.env`.
|
||||
if (lower.endsWith('.env') || lower.startsWith('.env.')) return true;
|
||||
const dot = lower.lastIndexOf('.');
|
||||
// No extension (or a bare dotfile like `.bashrc`): only the basename list applies.
|
||||
if (dot <= 0) return false;
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
* computed from that model's architecture. test/local-model-catalog.test.ts recomputes every
|
||||
* computed figure from a table of architectures and refuses a row that is not in it, so a
|
||||
* new row needs its model's config.json read, not a figure borrowed from a neighbour.
|
||||
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI
|
||||
* registry id as data: no code may compare it with a literal. Whether that CLI exists and can
|
||||
* take a custom model is checked against the registry at call time, in
|
||||
* src/local-model/harness-facts.ts, never here.
|
||||
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn?, readyMark? }`). The
|
||||
* `cli` is a CLI registry id as data: no code may compare it with a literal. Whether that CLI
|
||||
* exists, can take a custom model and can be installed from here is checked against the
|
||||
* registry at call time, in src/local-model/harness-facts.ts, never here. HARNESSES below
|
||||
* says what was measured for each row, and where.
|
||||
* - The schema checks more than fields. Across rows it requires: unique model ids and ranks,
|
||||
* unique harness `cli`s and ranks, `contexts` largest first, a `tested` entry naming a harness
|
||||
* row, and `chips` most specific first.
|
||||
@@ -24,8 +25,9 @@
|
||||
* throws on a bad row. test/local-model-catalog.test.ts pins that the stock constant parses,
|
||||
* so a bad row fails the gate and never reaches the route.
|
||||
*
|
||||
* Where each figure of the stock rows comes from, and what was checked (2026-11-08). [V] read
|
||||
* at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
|
||||
* Where each figure of the stock rows comes from, and what was checked (2026-11-08; the two rows
|
||||
* added on 2026-11-10, `ornith:9b-q4_k_m` and `muse-glimmer:30b-q4_k_m`, were read that day).
|
||||
* [V] read at the source that day, [M] measured on a Mac, [C] computed from [V] inputs:
|
||||
*
|
||||
* | Figure | Source | Checked |
|
||||
* |----------------------|-----------------------------------------------------------|--------------------------------------|
|
||||
@@ -56,14 +58,50 @@
|
||||
* | | | faster than the run without it |
|
||||
* | `tested` | our own run of the model with a harness on a real Mac | one entry, with its measured speed |
|
||||
* | `minMacosMajor` | the runtime's documentation (macOS 14) | yes, by the facts review |
|
||||
* | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. 32768 no, which is why |
|
||||
* | | higher"), and OpenCode v1.19.5's own fixed prompt (about | that row is gated |
|
||||
* | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | |
|
||||
* | harness `minContext` | 65536: the runtime's documentation for OpenCode ("64k or | 65536 yes. Nothing was run below it, |
|
||||
* | | higher"), and OpenCode v1.19.5's own fixed prompt (about | and no model row offers less, so |
|
||||
* | | 8,000 to 10,000 tokens) and compaction rule: at 32768 it | every row says 65536 (HARNESSES) |
|
||||
* | | leaves 14,000 to 16,000 tokens, less than one default | |
|
||||
* | | 2,000-line file read | |
|
||||
* | `chips` | the bandwidth column of | yes, read at the source for this |
|
||||
* | | https://github.com/ggml-org/llama.cpp/discussions/4167 | file |
|
||||
*
|
||||
* HARNESSES. What each harness row rests on. The runs of 2026-11-10 were made on Linux through
|
||||
* Codeman's own custom model launch, against a real model (Ollama 0.13.0, a 1.7B model on a CPU)
|
||||
* where it says REAL, and against a scripted endpoint that replays that runtime's stream of a
|
||||
* tool call where it says SCRIPTED. A scripted run proves the harness's side of the protocol,
|
||||
* never what a model does with its prompt. None of them ran on a Mac or on the pinned runtime.
|
||||
*
|
||||
* | Harness | First request (fixed prompt) | Tool round trip | `readyMark` |
|
||||
* |------------|-----------------------------------|----------------------------------------|--------------------------------------|
|
||||
* | `opencode` | 8,115 tokens, 11 tools (v1.19.5; | SCRIPTED (v1.19.5). REAL in our own | `┃`: the first painted frame holds |
|
||||
* | | more with user-level skills) | run on a Mac, see the `tested` entry | the composer; a prompt sent at the |
|
||||
* | | | | mark was submitted, 2 of 2 launches |
|
||||
* | `claude` | about 36,000 tokens, which is | not run again for this table (the row | none here: the CLI registry's |
|
||||
* | | `slowFirstTurn` | shipped in 1.50.0) | `composerReadyMark` stands |
|
||||
* | `pi` | 2,096 tokens, 4 tools (v1.5.2, | REAL (v1.5.2): tool call, tool result, | none: its composer rules are painted |
|
||||
* | | the runtime's own count) | final answer, the file on disk | before it takes Enter (2 of 2 |
|
||||
* | | | | prompts sent at the rule stayed |
|
||||
* | | | | unsent), and it draws nothing that |
|
||||
* | | | | is a contract once it does |
|
||||
* | `deepseek` | about 7,650 tokens, 25 tools | SCRIPTED only, on both versions | none here: the CLI registry's `❯` |
|
||||
* | | (0.1.1-rc.2); 6,983 tokens, 27 | (0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1 | stands; a prompt sent at it was |
|
||||
* | | tools (0.2.2-rc.1) | and 0.2.2-rc.1 with dsh-TUI 0.19.0). | submitted, 2 of 2 launches. On |
|
||||
* | | | REAL: the launch and the model name | dsh-TUI 0.19.0 a setup guide draws |
|
||||
* | | | reached the runtime, no turn finished | `❯` too until it was finished once |
|
||||
*
|
||||
* Every one of them sent the `name:tag` model id unchanged. `pi` was gated until 2026-11-10 for
|
||||
* two open questions, both answered by its run: the id (above), and the window, which Codeman
|
||||
* now passes to it (src/custom-model-injection.ts; without it pi takes an unknown model for a
|
||||
* 128,000-token one). `deepseek` is a LAUNCHER: its install command leaves it with nothing to
|
||||
* launch, so src/local-model/harness-facts.ts never reports it installable and a Mac is offered
|
||||
* it only where it was already set up. It is offered on a scripted run and one unfinished real
|
||||
* one: gate the row again (`gated: true`) should a run on a Mac not finish a turn. Two things
|
||||
* about dsh-TUI 0.19.0 that a client of its `readyMark` must know: until a person has finished
|
||||
* its setup guide once, every session opens on that guide and the guide's cursor is the mark
|
||||
* (the codeman skill reads the pane after the mark for that reason), and its transcript file
|
||||
* was renamed, so Codeman's `last-response` is empty for it for now.
|
||||
*
|
||||
* MEMORY. The need is `bytes + heldBytesPerToken x context + overheadBytes + peakExtraBytes`
|
||||
* (src/local-model/recommend.ts). The two runners spend memory differently, so a row's three
|
||||
* figures are filled by the rule of its runner:
|
||||
@@ -74,8 +112,10 @@
|
||||
* src/local-model/runtime.ts, q8_0: 34 bytes per 32 values), and does not grow after that.
|
||||
* "MTP" below is a GGUF with a multi-token-prediction layer (`nextn_predict_layers` 1 in
|
||||
* its header, read on 2026-11-08 from the first bytes of each model layer: the two `-mtp`
|
||||
* tags and `qwen3.8:27b-q8_0` have it, the two 9B tags and North Mini Code do not). For
|
||||
* such a model the runtime always passes `--spec-type draft-mtp --spec-draft-n-max N`, N
|
||||
* tags and `qwen3.8:27b-q8_0` have it, the two 9B tags and North Mini Code do not; read
|
||||
* the same way on 2026-11-10, `ornith:9b-q4_k_m` and `muse-glimmer:30b-q4_k_m` do not
|
||||
* either). For such a model the runtime always passes
|
||||
* `--spec-type draft-mtp --spec-draft-n-max N`, N
|
||||
* being the tag's own `draft_num_predict` (its params layer: 4 for
|
||||
* `qwen3.8:27b-mtp-q4_k_m`, 2 for `qwen3.6:35b-a3b-mtp-q4_k_m`) or the runtime's default
|
||||
* of 4 (`qwen3.8:27b-q8_0` sets none).
|
||||
@@ -83,9 +123,11 @@
|
||||
* adds its prediction layer, whose cache llama-server keeps in a draft context of the
|
||||
* same length in the draft's own cache type, f16 by default (2 bytes per value).
|
||||
* - overhead = the fixed caches (the f32 recurrent state of the linear-attention layers,
|
||||
* or the sliding-window cache of `n_swa + 512` cells), held 1 + N times for an MTP
|
||||
* model (llama.cpp keeps one recurrent state per drafted token for rollback,
|
||||
* `need_n_rs_seq()`), + the context checkpoints: `LLAMA_ARG_CTX_CHECKPOINTS` host copies
|
||||
* or the sliding-window cache of `n_swa + 512` cells: `size_swa` in
|
||||
* src/llama-kv-cache-iswa.cpp, the cache every model with a sliding window gets), held
|
||||
* 1 + N times for an MTP model (llama.cpp keeps one recurrent state per drafted token
|
||||
* for rollback, `need_n_rs_seq()`), + the context checkpoints:
|
||||
* `LLAMA_ARG_CTX_CHECKPOINTS` host copies
|
||||
* (`RUNTIME_CTX_CHECKPOINTS`, 8) of the state that cannot be rolled back (the recurrent
|
||||
* state, or the sliding-window cache), + 1.0 GB for the runner's own buffers (calibrated
|
||||
* on a third-party table of a 27B model on llama.cpp: 8.4 GB at 4,096 tokens with
|
||||
@@ -125,26 +167,59 @@
|
||||
* checked. For an `mlx` row it proves only the weights (issue 19417): the figures above are
|
||||
* all that stands between such a row and swap.
|
||||
*
|
||||
* Models set aside on purpose (a weekly review should look at them again):
|
||||
* Models set aside on purpose (a weekly review should look at them again). Every reason was
|
||||
* read again at its source on 2026-11-10:
|
||||
* - `ripple-preview:65b` (Ricursive, Apache-2.0, a dense 65B published on 2026-11-09;
|
||||
* Terminal-Bench 2.1 80.5 and SWE-bench Verified 76.5 on its card; 38.8 GB as a GGUF build,
|
||||
* 45.8 GB as the MLX build a Mac pulls): every build requires runtime 0.40.16, one above
|
||||
* the pin. Computed from its config.json by the llamacpp rule, its GGUF build would be the
|
||||
* first row between the 27B and the 122B (47 GB at 131,072 tokens: 64 and 96 GB Macs, slow
|
||||
* below an Ultra chip). Look at it the day the pin moves.
|
||||
* - `bonsai-2:27b`, `bonsai-2:9b` (ternary builds of Qwen3.8-27B and Qwen3.5-9B; the 27B
|
||||
* would make a 24 GB Mac `ok`): the vendor reports no SWE-bench and no agent loop, and
|
||||
* whether 2-bit weights keep tool calls reliable was never measured.
|
||||
* would make a 24 GB Mac `ok`): the vendor reports no SWE-bench and no agent loop (its card
|
||||
* of 2026-11-04 still lists BFCL v3 only), and whether 2-bit weights keep tool calls
|
||||
* reliable was never measured.
|
||||
* - `ornith-1.5:9b`, `ornith-1.5:35b`, `ornith-1.5:397b` (GGUF only): strong cards, but their
|
||||
* manifests name no renderer and no tool parser, so whether tool calls work through the
|
||||
* runtime is unknown (runtime pull request 17965 is open). `ornith-1.5:122b` has both.
|
||||
* runtime is unknown (runtime pull request 17965 is still open). `ornith-1.5:122b` has
|
||||
* both.
|
||||
* - `ornith:35b` (Ornith 1.0 35B-A3B, MIT, with a renderer and a tool parser; SWE-bench
|
||||
* Verified 75.6 against 73.4 for Qwen3.6 35B-A3B on Ornith's card): runtime issue 16992,
|
||||
* open, reports its tool calls arriving as text in long agent chains, and its size is
|
||||
* served by the pair we ran. Its 9B sibling `ornith:9b-q4_k_m` is listed: see that row
|
||||
* for why.
|
||||
* - `gpt-oss:20b`: weak in agentic coding (SWE-bench Verified 52.4, Terminal-Bench 2.1 15.2
|
||||
* in NVIDIA's table).
|
||||
* - `flint:40b`: runtime issue 19191 breaks integer tool arguments after the first call.
|
||||
* - `flint:40b`: runtime issue 19191 (open) breaks integer tool arguments after the first
|
||||
* call.
|
||||
* - `lfm2.5:24b-a2b`: its licence allows commercial use only below USD 10M of revenue.
|
||||
* - `laguna-xs-2.1`: the runtime's own page names an open problem with it on macOS.
|
||||
* `laguna-s-2.1`: open runtime issues 17602 and 17632.
|
||||
* - `qwen3.8-flash-next:125b`: the Qwen Community License, not an open licence.
|
||||
* - `qwen3.8-flash-next:125b`: the Qwen Community License, not an open licence, and its
|
||||
* builds require runtime 0.40.16 since 2026-11-09.
|
||||
* - `nemotron-3.5-super:120b` (79.6 GB as the MLX build a Mac pulls): Terminal-Bench 2.1
|
||||
* 60.7 and SWE-bench Verified 71.1 in NVIDIA's table, below `ornith-1.5:122b` at a larger
|
||||
* download, so it adds no choice a 128 GB Mac would take.
|
||||
* - `qwen3.5:4b-q4_k_m` (3.3 GB, it would fit a 16 GB Mac with room): runtime issue 18916,
|
||||
* open, has it answer with thinking only in a long conversation at 65,536 tokens.
|
||||
* - `qwen3-coder-next` (51.7 GB): the generation before Qwen3.6. Its card shows its results
|
||||
* as pictures only, so there is no figure to rank it by against the rows listed here.
|
||||
* - `muse-glimmer:30b-mlx` and the `-dflash` tags of that model: open runtime issues 17656
|
||||
* and 18269 on the MLX build, and no rule here covers the memory of a separate draft
|
||||
* model. Its GGUF build `muse-glimmer:30b-q4_k_m` is listed.
|
||||
* - The plain tags `qwen3.5:9b`, `mimo-v2.6:9b` and `qwen3.8:27b`: a Mac pulls their MLX
|
||||
* builds, whose 8 GiB of prefix snapshots do not fit the Macs a 9B or a 27B is for. Their
|
||||
* GGUF builds are listed instead.
|
||||
* - `qwen3.5:122b-a10b`: replaced by `ornith-1.5:122b`, a fine-tune of it (SWE-bench
|
||||
* Verified 84.5 against 72.0) whose MLX build carries a renderer and a tool parser.
|
||||
*
|
||||
* One open issue touches rows that ARE listed: runtime issue 16992 (above). Its one diagnosed
|
||||
* cause sits in the tool parser itself (it closes a call at the first closing tag, so a tool
|
||||
* argument that quotes a whole tool call is cut off; the fix, runtime pull request 17021, is
|
||||
* open). The names `ornith` and `qwen3.5` map to that one parser in the pinned runtime
|
||||
* (model/parsers/parsers.go), so every Qwen-family row here shares it, the pair we ran
|
||||
* included. The check after a download asks for one plain tool call and cannot see this.
|
||||
*
|
||||
* `tested` has one entry: `qwen3.6:35b-a3b` with OpenCode on an Apple M5 Pro with 48 GB,
|
||||
* 2026-11-06, 67.7 tokens per second. Only a Mac of exactly that chip and memory size is told
|
||||
* "Recommended" and shown that speed in place of the estimate. Every other row was never run
|
||||
@@ -222,6 +297,8 @@ const LocalHarnessEntrySchema = z
|
||||
rank: z.number().int(),
|
||||
gated: z.boolean().optional(),
|
||||
slowFirstTurn: z.boolean().optional(),
|
||||
// One literal a script waits for: no white space, so the terminal's own spacing cannot split it.
|
||||
readyMark: z.string().min(1).max(64).regex(/^\S+$/).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -336,7 +413,7 @@ const MLX_PREFIX_CACHE_BYTES = 8 * 1024 * 1024 * 1024;
|
||||
*/
|
||||
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
schema: 1,
|
||||
updated: '2026-11-08',
|
||||
updated: '2026-11-10',
|
||||
// The oldest macOS the model runtime supports.
|
||||
minMacosMajor: 14,
|
||||
// Ordered by rank. The higher rank wins among models that fit and whose speed is graded
|
||||
@@ -344,7 +421,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
// that are left out and why.
|
||||
models: [
|
||||
{
|
||||
// Small Macs (16 to 32 GB, `limited`): the base model of the row below.
|
||||
// Small Macs (18 to 32 GB): the base model of the two rows below.
|
||||
id: 'qwen3.5:9b-q4_k_m',
|
||||
label: 'Qwen3.5 9B',
|
||||
license: 'Apache-2.0',
|
||||
@@ -365,7 +442,35 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Small Macs (16 to 32 GB, `limited`). MiMo-V2.6-Distill-Qwen-9B, a fine-tune of
|
||||
// The model a 16 GB Mac can hold: 9.3 GB at 65,536 tokens, tight. Ornith 1.0 9B, a
|
||||
// fine-tune of Qwen3.5-9B for coding agents, as a GGUF build without a vision projector,
|
||||
// so 0.9 GB smaller than the rows around it. SWE-bench Verified 69.4, SWE-bench Pro 42.9
|
||||
// and Terminal-Bench 2.1 43.1, against 53.2, 31.3 and 21.3 for its base (Ornith's card,
|
||||
// which puts that base 7 points under Xiaomi's figure for it: the two cards do not
|
||||
// compare). Ranked under MiMo for that, and because runtime issue 16992 was filed on its
|
||||
// 35B sibling, which has the same renderer and parser (see the header): it is the pick
|
||||
// only where nothing else fits. Text only.
|
||||
id: 'ornith:9b-q4_k_m',
|
||||
label: 'Ornith 1.0 9B',
|
||||
license: 'MIT',
|
||||
paramsB: 9,
|
||||
runner: 'llamacpp',
|
||||
bytes: 5_629_110_568,
|
||||
// The architecture of the row above (its config.json and its GGUF header say so), so
|
||||
// its figures.
|
||||
heldBytesPerToken: 17_408,
|
||||
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 2,
|
||||
// Not measured: the 9.3 of the row above, the same architecture and the same weights file
|
||||
// size (5.63 GB without the projector).
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Small Macs (18 to 32 GB). MiMo-V2.6-Distill-Qwen-9B, a fine-tune of
|
||||
// Qwen3.5-9B for code and agents: SWE-bench Verified 61.1 and SWE-bench Pro 44.6,
|
||||
// against 60.0 and 32.0 for its base (Xiaomi's card). Requires exactly the pinned
|
||||
// runtime, 0.40.15.
|
||||
@@ -375,14 +480,14 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
paramsB: 9,
|
||||
runner: 'llamacpp',
|
||||
bytes: 6_550_811_033,
|
||||
// The architecture and the quantization of the row above, so its figures.
|
||||
// The architecture and the quantization of the base model's row, so its figures.
|
||||
heldBytesPerToken: 17_408,
|
||||
overheadBytes: 52_690_944 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 2,
|
||||
// Not measured: the 9.3 of the row above, the same architecture and file size.
|
||||
rank: 3,
|
||||
// Not measured: the 9.3 of the base model's row, the same architecture and file size.
|
||||
speedPer100Gbps: 9.3,
|
||||
tested: [],
|
||||
},
|
||||
@@ -404,12 +509,39 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 3,
|
||||
rank: 4,
|
||||
// Not measured: the 13.6 measured for Qwen3.6 35B-A3B on llama.cpp, which has the same
|
||||
// 3 billion active parameters.
|
||||
speedPer100Gbps: 13.6,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Meta's 30B for local agents, a dense 27.9B language model with a vision encoder:
|
||||
// SWE-bench Verified 76.0, SWE-bench Pro 51.2, Terminal-Bench 2.1 51.7 (Meta's card). Its
|
||||
// K/V cache is the smallest of the 27B class (2 K/V heads of 128, and three layers in
|
||||
// four look at a window of 2,048 tokens), so it is the first model of that class a 32 GB
|
||||
// Mac can hold: 21.2 GB at 65,536 tokens, tight. Dense, so it is slow below a Max chip.
|
||||
// Its own window ends at 131,072 tokens.
|
||||
id: 'muse-glimmer:30b-q4_k_m',
|
||||
label: 'Muse Glimmer 30B',
|
||||
license: 'Apache-2.0',
|
||||
paramsB: 29.6,
|
||||
runner: 'llamacpp',
|
||||
bytes: 18_157_010_252,
|
||||
// 13 full-attention layers x 2 K/V heads x 128 x 2 x 34/32.
|
||||
heldBytesPerToken: 7_072,
|
||||
// 39 sliding-window layers: (2,048 + 512) cells x 2 x 128 x 2 x 34/32 = 54,312,960, live
|
||||
// and in each context checkpoint.
|
||||
overheadBytes: 54_312_960 * (1 + LLAMACPP_CTX_CHECKPOINTS) + LLAMACPP_RUNNER_BYTES + LLAMACPP_PROMPT_CACHE_BYTES,
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [131072, 65536],
|
||||
rank: 5,
|
||||
// Not measured: the 2.95 of Qwen3.8 27B, a dense model whose weights are the same size on
|
||||
// llama.cpp (16.8 GB each without the projector).
|
||||
speedPer100Gbps: 2.95,
|
||||
tested: [],
|
||||
},
|
||||
{
|
||||
// Macs of 36 GB on a slower chip: the model of the tested pair, as a GGUF build whose
|
||||
// memory does not grow after it loaded. With its multi-token-prediction layer.
|
||||
@@ -430,7 +562,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 4,
|
||||
rank: 6,
|
||||
// Measured: 54.5 tokens per second on 400 GB/s (llama.cpp, without prediction).
|
||||
speedPer100Gbps: 13.6,
|
||||
tested: [],
|
||||
@@ -448,7 +580,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
...MEASURED_MEMORY,
|
||||
figures: 'measured',
|
||||
contexts: [131072, 65536],
|
||||
rank: 5,
|
||||
rank: 7,
|
||||
// Measured: 67.8 tokens per second on 400 GB/s with MLX. The one Mac we tested ran it at
|
||||
// 67.7 on 307 GB/s, so on other chips the estimate is on the low side.
|
||||
speedPer100Gbps: 16.9,
|
||||
@@ -473,7 +605,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 6,
|
||||
rank: 8,
|
||||
// Not measured: the 2.95 of the row below scaled by file size.
|
||||
speedPer100Gbps: 1.7,
|
||||
tested: [],
|
||||
@@ -497,7 +629,7 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: 0,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072, 65536],
|
||||
rank: 7,
|
||||
rank: 9,
|
||||
// Measured: 11.8 tokens per second on 400 GB/s (llama.cpp, Q4_K_M, without prediction).
|
||||
// This build predicts several tokens at a time, so it is faster than this figure says.
|
||||
speedPer100Gbps: 2.95,
|
||||
@@ -521,20 +653,23 @@ export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
peakExtraBytes: MEASURED_MEMORY.peakExtraBytes,
|
||||
figures: 'estimated',
|
||||
contexts: [262144, 131072],
|
||||
rank: 8,
|
||||
rank: 10,
|
||||
// Not measured: the 16.9 of Qwen3.6 35B-A3B on MLX x 3/10, its active parameters
|
||||
// against these, rounded down.
|
||||
speedPer100Gbps: 5,
|
||||
tested: [],
|
||||
},
|
||||
],
|
||||
// The higher rank wins when a model can run with more than one harness.
|
||||
// The higher rank wins when a model can run with more than one harness. What each row rests
|
||||
// on is the HARNESSES table of the file header.
|
||||
harnesses: [
|
||||
{ cli: 'opencode', minContext: 65536, rank: 3 },
|
||||
{ cli: 'opencode', minContext: 65536, rank: 4, readyMark: '┃' },
|
||||
// Sends a fixed prompt of about 36,000 tokens, so the first answer takes minutes.
|
||||
{ cli: 'claude', minContext: 65536, rank: 2, slowFirstTurn: true },
|
||||
// Gated: neither its 32768 minimum nor its handling of a `name:tag` model id is verified.
|
||||
{ cli: 'pi', minContext: 32768, rank: 1, gated: true },
|
||||
{ cli: 'claude', minContext: 65536, rank: 3, slowFirstTurn: true },
|
||||
// The smallest first request of the four (2,096 tokens). No `readyMark`: see HARNESSES.
|
||||
{ cli: 'pi', minContext: 65536, rank: 2 },
|
||||
// A launcher: offered only where it is already set up, never installed from here.
|
||||
{ cli: 'deepseek', minContext: 65536, rank: 1 },
|
||||
],
|
||||
// Memory bandwidth in GB/s, most specific first. Only chips that shipped and are in the source
|
||||
// table are listed. Where one name covers two bandwidths (M3 Max 300 or 400, M4 Max 410 or 546,
|
||||
|
||||
@@ -194,6 +194,21 @@ function opencodeModelLimit(contextLength: number | undefined): { context: numbe
|
||||
return { context: total, output };
|
||||
}
|
||||
|
||||
/**
|
||||
* The two fields pi's model list and the dsh provider patch (which is built on pi's model
|
||||
* layer) take for a model's window: `contextWindow`, the FULL window, and `maxTokens`, the
|
||||
* reply's share of it (a quarter, capped at 8192 on a large window). Empty for an unknown or
|
||||
* non-finite size: the CLI then keeps its own default, and no limit is invented here.
|
||||
*/
|
||||
function modelWindowFields(contextLength: number | undefined): { contextWindow?: number; maxTokens?: number } {
|
||||
if (contextLength === undefined || !Number.isFinite(contextLength) || contextLength <= 0) return {};
|
||||
return {
|
||||
contextWindow: Math.trunc(contextLength),
|
||||
// A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
|
||||
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
|
||||
};
|
||||
}
|
||||
|
||||
function renderConfigContent(
|
||||
template: 'opencode-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
@@ -260,19 +275,7 @@ function renderConfigFile(
|
||||
apiKeyEnv: CUSTOM_MODEL_API_KEY_ENV_VAR,
|
||||
api: 'openai-completions',
|
||||
baseURL: baseUrl,
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? {
|
||||
contextWindow: Math.trunc(contextLength),
|
||||
// A quarter of the window keeps the reply inside the prompt's share; 8192 caps it on a large window.
|
||||
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
models: [{ id: modelId, name: modelId, ...modelWindowFields(contextLength) }],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -327,6 +330,11 @@ function renderConfigFile(
|
||||
// true` is required too: pi does not automatically send `Authorization: Bearer
|
||||
// <apiKey>` just because `apiKey` is set (per the same doc) — without it, a real
|
||||
// (non-llama.cpp) endpoint that actually checks the key would reject every request.
|
||||
// The model entry carries the endpoint's context window when it is known. 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 65,536 or 32,768 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`.
|
||||
return {
|
||||
content: JSON.stringify(
|
||||
{
|
||||
@@ -336,7 +344,7 @@ function renderConfigFile(
|
||||
apiKey,
|
||||
api: 'openai-completions',
|
||||
authHeader: true,
|
||||
models: [{ id: modelId }],
|
||||
models: [{ id: modelId, ...modelWindowFields(contextLength) }],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1669,6 +1669,168 @@ export async function removeAgentSkillFrom(skillDir: string): Promise<AgentSkill
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// omp approval hooks
|
||||
// ============================================================================
|
||||
|
||||
/** Marks the hook file this module owns inside a workspace. */
|
||||
const OMP_APPROVAL_HOOK_MARKER = 'CODEMAN_OMP_APPROVAL_HOOK_V1';
|
||||
|
||||
/**
|
||||
* omp's own hook directory, workspace-relative. omp scans `<cwd>/.claude/hooks/pre`
|
||||
* and `<cwd>/.claude/hooks/post` (`projectDir` for the `claude` persona) plus a
|
||||
* user-level `<agentDir>/hooks`, and loads each `.ts`/`.js` file's default export as
|
||||
* `(pi: HookAPI) => void`. A project-level `pre` hook is deliberately the target: it
|
||||
* travels with the workspace the session actually runs in, so no per-machine or
|
||||
* per-user install is needed and removing the workspace removes the hook.
|
||||
*/
|
||||
export const OMP_APPROVAL_HOOK_DIR = '.claude/hooks/pre';
|
||||
export const OMP_APPROVAL_HOOK_FILE = 'codeman-approval.ts';
|
||||
|
||||
/**
|
||||
* Source of the hook that turns omp's own approval events into Codeman hook events.
|
||||
*
|
||||
* WHY this exists: omp installs no Claude-style `settings.local.json` hook block — its
|
||||
* hooks are modules — so without this file an omp pane asking for tool approval is
|
||||
* silent: `/api/approvals` stays empty, the tab never goes red and no notification
|
||||
* fires. The registry declares it through `capabilities.approvalHookFile`, NOT
|
||||
* `hooks: 'always'` (which would also promise `stop` and answerable dialogs).
|
||||
*
|
||||
* The mapping uses Claude's event names, because the server routes on them
|
||||
* (`APPROVAL_KIND_BY_EVENT` in web/routes/hook-event-routes.ts):
|
||||
* - `tool_approval_requested` -> `permission_prompt` (opens an alert-only inbox item)
|
||||
* - `tool_approval_resolved` -> `elicitation_complete` (resolves it, whether the user
|
||||
* approved or denied, so a denied dialog does not leave a red tab forever)
|
||||
*
|
||||
* Several calls in one batch can each wait on their own prompt, and the inbox keeps one
|
||||
* item per session, so the module tracks pending `toolCallId`s and reports the close only
|
||||
* when the last one resolves. That set drains because omp pairs every request with a
|
||||
* resolve, an aborted prompt included (its approval wrapper emits `approved: false` from
|
||||
* the catch, omp 18.18.6 `extensibility/extensions/wrapper.ts`); only omp dying mid-prompt
|
||||
* skips it, which leaves the alert up exactly as it would without the set. Reports go out
|
||||
* in order (one chain), so a fast approve cannot overtake the prompt that opened it.
|
||||
*
|
||||
* `reason` is omp's POLICY note (a restricted folder, a critical-pattern match), not the
|
||||
* command, and is absent on an ordinary mode-raised prompt, so it rides `message`.
|
||||
*
|
||||
* HTTPS: `CODEMAN_API_URL` is loopback HTTPS with a self-signed certificate on `--https`
|
||||
* and tailscale installs (Codeman's own hook curl passes `-k`, the DeepSeek shim sets
|
||||
* `rejectUnauthorized: false`). omp runs on Bun, whose fetch verifies certificates by
|
||||
* default, so an `https:` URL gets Bun's `tls: { rejectUnauthorized: false }`.
|
||||
*
|
||||
* ⚠️ Fails OPEN and SILENT, like the memory-gate hook: a hook that throws would break
|
||||
* the approval flow it exists to report. Every path catches, and nothing is awaited
|
||||
* unbounded — each POST carries a short AbortSignal and the handlers never await it.
|
||||
*/
|
||||
export function generateOmpApprovalHook(): string {
|
||||
return `// ${OMP_APPROVAL_HOOK_MARKER} — auto-generated by Codeman; safe to delete, rewritten when a session is created.
|
||||
//
|
||||
// Reports omp's tool-approval dialogs to Codeman's /api/hook-event so an omp pane
|
||||
// raises a tab alert and a desktop notification. Answer the dialog in the terminal.
|
||||
// Codeman owns this file: a foreign file of the same name is never overwritten.
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { HookAPI } from "@oh-my-pi/pi-coding-agent/extensibility/hooks";
|
||||
|
||||
const API_URL = process.env.CODEMAN_API_URL;
|
||||
const SESSION_ID = process.env.CODEMAN_SESSION_ID;
|
||||
const SECRET_FILE = process.env.CODEMAN_HOOK_SECRET_FILE;
|
||||
// Loopback HTTPS (--https, tailscale) uses a self-signed certificate, which Bun's fetch
|
||||
// refuses by default; Codeman's own hook curl passes -k for the same reason.
|
||||
const TLS = API_URL?.startsWith("https:") ? { tls: { rejectUnauthorized: false } } : {};
|
||||
|
||||
/** POST one Codeman hook event. Never throws; bounded by its timeout. */
|
||||
async function send(event: string, data: Record<string, unknown>): Promise<void> {
|
||||
if (!API_URL || !SESSION_ID) return;
|
||||
try {
|
||||
const secret = SECRET_FILE ? (await readFile(SECRET_FILE, "utf8")).trim() : "";
|
||||
await fetch(\`\${API_URL}/api/hook-event\`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Codeman-Hook-Secret": secret },
|
||||
body: JSON.stringify({ event, sessionId: SESSION_ID, data }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
...TLS,
|
||||
});
|
||||
} catch {
|
||||
// Best effort by contract: a failed report must never disturb the dialog itself.
|
||||
}
|
||||
}
|
||||
|
||||
// One chain, so reports arrive in the order omp raised them: a fast approve must not
|
||||
// overtake the prompt it answers and leave the alert up.
|
||||
let chain: Promise<void> = Promise.resolve();
|
||||
function report(event: string, data: Record<string, unknown>): void {
|
||||
chain = chain.then(() => send(event, data));
|
||||
}
|
||||
|
||||
export default function codemanApprovalHook(pi: HookAPI): void {
|
||||
// Calls in one batch can each wait on their own prompt, and Codeman keeps one alert per
|
||||
// session, so it is cleared only when the LAST pending approval resolves.
|
||||
const pending = new Set<string>();
|
||||
|
||||
pi.on("tool_approval_requested", (event) => {
|
||||
pending.add(event.toolCallId);
|
||||
// \`reason\` is omp's policy note (restricted folder, critical pattern), not the command.
|
||||
report("permission_prompt", {
|
||||
tool_name: event.toolName,
|
||||
...(event.reason ? { message: event.reason } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("tool_approval_resolved", (event) => {
|
||||
pending.delete(event.toolCallId);
|
||||
if (pending.size > 0) return;
|
||||
report("elicitation_complete", {
|
||||
tool_name: event.toolName,
|
||||
approved: event.approved,
|
||||
});
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export type ApprovalHookApplyResult = 'installed' | 'refreshed' | 'unchanged' | 'foreign' | 'symlink';
|
||||
|
||||
/**
|
||||
* Install or refresh the omp approval hook in `<workspace>/.claude/hooks/pre/`.
|
||||
*
|
||||
* Ownership: the file carries `OMP_APPROVAL_HOOK_MARKER` (the generated module has it
|
||||
* in its header, and the check takes it anywhere in the file). A file
|
||||
* WITHOUT the marker is somebody else's hook and is left untouched (`foreign`) — the
|
||||
* same posture `installAgentSkillInto` takes, so Codeman never clobbers user work.
|
||||
* EVERY component below the workspace (`.claude`, `.claude/hooks`, `.claude/hooks/pre`)
|
||||
* and the target file itself is `lstat`ed, and a symlink anywhere is refused rather than
|
||||
* written through: a linked `.claude/hooks` once put `pre/<file>` in the link's target.
|
||||
*
|
||||
* Idempotent: an unchanged file is not rewritten (`unchanged`), so this is safe to call
|
||||
* on every omp session create.
|
||||
*/
|
||||
export async function applyApprovalHookFile(workspace: string, fileName: string): Promise<ApprovalHookApplyResult> {
|
||||
const hookDir = join(workspace, OMP_APPROVAL_HOOK_DIR);
|
||||
const target = join(hookDir, fileName);
|
||||
return withSettingsLock(target, async () => {
|
||||
let component = workspace;
|
||||
for (const part of [...OMP_APPROVAL_HOOK_DIR.split('/'), fileName]) {
|
||||
component = join(component, part);
|
||||
if (await isSymlink(component)) return 'symlink';
|
||||
}
|
||||
|
||||
let existing: string | null = null;
|
||||
try {
|
||||
existing = await readFile(target, 'utf-8');
|
||||
} catch {
|
||||
// absent: fresh install
|
||||
}
|
||||
if (existing !== null && !existing.includes(OMP_APPROVAL_HOOK_MARKER)) return 'foreign';
|
||||
|
||||
const content = generateOmpApprovalHook();
|
||||
if (existing === content) return 'unchanged';
|
||||
|
||||
await mkdir(hookDir, { recursive: true });
|
||||
await writeSkillFileAtomic(target, content);
|
||||
return existing === null ? 'installed' : 'refreshed';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code's project skills directory, and the meaning `agentSkillInjection: true`
|
||||
* had before `capabilities.agentSkillDir` existed.
|
||||
|
||||
@@ -263,16 +263,30 @@ export async function isManagedLocalEndpoint(id: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The names of the live sessions that use the endpoint `endpointId`, in the order given.
|
||||
* Pure. What `DELETE /api/local-model` answers its 409 with.
|
||||
* What a session is called where a person reads it. A session an agent started (a worker of
|
||||
* the codeman skill, `codeman agent spawn`) has no name of its own, and its tab then shows
|
||||
* the last folder of its working directory, else the first eight characters of its id
|
||||
* (`getSessionName()` in app.js). The same steps here, so that a sentence which lists
|
||||
* sessions never lists an empty name. Pure.
|
||||
*/
|
||||
export function sessionLabel(session: { id?: string; name?: string; workingDir?: string }): string {
|
||||
const name = (session.name ?? '').trim();
|
||||
if (name) return name;
|
||||
const folder = (session.workingDir ?? '').split('/').filter(Boolean).pop();
|
||||
return folder || (session.id ?? '').slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live sessions that use the endpoint `endpointId`, in the order given, each by the name
|
||||
* its tab shows (`sessionLabel()`). Pure. What the feature answers a 409 `sessions-open` with.
|
||||
*/
|
||||
export function sessionNamesUsingEndpoint(
|
||||
sessions: Iterable<{ name: string; customModel?: { endpointId: string } }>,
|
||||
sessions: Iterable<{ id?: string; name: string; workingDir?: string; customModel?: { endpointId: string } }>,
|
||||
endpointId: string = LOCAL_MODEL_ENDPOINT_ID
|
||||
): string[] {
|
||||
const names: string[] = [];
|
||||
for (const session of sessions) {
|
||||
if (session.customModel?.endpointId === endpointId) names.push(session.name);
|
||||
if (session.customModel?.endpointId === endpointId) names.push(sessionLabel(session));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,18 @@
|
||||
* mechanism (`capabilities.customModelInjection.kind` is `unsupported`);
|
||||
* - reports `label` from the registry entry and `installed` from the same probe the page
|
||||
* render and `GET /api/clis` use (`utils/cli-installed-probes.ts`), asked once per build;
|
||||
* - reports `installable` only for a STOCK CLI that is not installed and whose install
|
||||
* command for this platform needs no administrator rights (`installCommandNeedsAdmin()`),
|
||||
* and then carries that command as `installCommand`, so the consent screen can print the
|
||||
* - reports `installable` only for a STOCK CLI that is not installed, whose install command
|
||||
* for this platform needs no administrator rights (`installCommandNeedsAdmin()`) and which
|
||||
* is not a launcher (`discovery.launcherProfile`: its install command leaves it with nothing
|
||||
* to launch, and what it launches is third-party code with a consent of its own in the Run
|
||||
* menu), and then carries that command as `installCommand`, so the consent screen can print the
|
||||
* literal text. The command is the COMPILED stock one (`stockInstallCommand()`), never the
|
||||
* one a `clis.json` override supplies: it is exactly what the install job would run, and
|
||||
* an override file must not be able to change that;
|
||||
* - supplies `acceptsModelId` (`cliAcceptsCustomModelId()`), the test a custom model launch
|
||||
* applies to the model id before anything is written.
|
||||
* applies to the model id before anything is written;
|
||||
* - passes on the registry's `capabilities.composerReadyMark` as `readyMark` when it holds no
|
||||
* white space (a script waits for it as one literal).
|
||||
*
|
||||
* Rules:
|
||||
* - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue
|
||||
@@ -156,19 +160,23 @@ export async function buildHarnessFacts(
|
||||
}
|
||||
|
||||
let installCommand: string | undefined;
|
||||
if (!installed && entry.stock) {
|
||||
// A launcher (`discovery.launcherProfile`) is not startable after its install command: it
|
||||
// still needs something to launch, which that command does not install.
|
||||
if (!installed && entry.stock && entry.discovery.launcherProfile === undefined) {
|
||||
const command = resolved.installCommandFor(entry);
|
||||
if (command !== undefined && command.trim() !== '' && !installCommandNeedsAdmin(command)) {
|
||||
installCommand = command;
|
||||
}
|
||||
}
|
||||
|
||||
const registryMark = entry.capabilities.composerReadyMark;
|
||||
facts.push({
|
||||
cli: row.cli,
|
||||
label: entry.label,
|
||||
installed,
|
||||
installable: installCommand !== undefined,
|
||||
...(installCommand !== undefined ? { installCommand } : {}),
|
||||
...(registryMark !== undefined && !/\s/.test(registryMark) ? { readyMark: registryMark } : {}),
|
||||
acceptsModelId: (modelId: string) => cliAcceptsCustomModelId(entry, modelId),
|
||||
});
|
||||
}
|
||||
|
||||
+975
-96
File diff suppressed because it is too large.
Load diff
@@ -28,9 +28,10 @@
|
||||
* gated, installed or installable, and accepting the model id.
|
||||
* - Ranking, lexicographic: tested on a Mac like this before not tested (a measurement beats
|
||||
* a formula); ok before unknown before slow; the higher model rank; the higher harness
|
||||
* rank; the larger context. The pick is the first candidate, the alternatives are the next
|
||||
* four MODELS, each at its own best context and harness, so a model is always offered at
|
||||
* the largest context it fits at.
|
||||
* rank; the larger context. The pick is the first candidate, the alternatives are EVERY
|
||||
* other model that fits (`MAX_ALTERNATIVES` is above what the catalogue holds), each at its
|
||||
* own best context and harness, so a model is always offered at the largest context it
|
||||
* fits at.
|
||||
* - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not
|
||||
* dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped`
|
||||
* with what is missing.
|
||||
@@ -109,8 +110,13 @@ export const SLOW_BELOW_TOKENS_PER_SECOND = 10;
|
||||
export const LIMITED_BELOW_PARAMS_B = 12;
|
||||
/** What must stay free on the disk after the model download. */
|
||||
export const DISK_HEADROOM_BYTES = 10 * GIB;
|
||||
/** How many other models are listed under the pick. */
|
||||
export const MAX_ALTERNATIVES = 4;
|
||||
/**
|
||||
* How many other models are listed under the pick, at most. Above what the stock catalogue
|
||||
* holds (10 models: 9 beside the pick), so a Mac is shown every model that fits it and the
|
||||
* install accepts every one of them. Still a bound: test/local-model-catalog.test.ts fails for
|
||||
* a catalogue that outgrows it, so nothing drops out of the list unseen.
|
||||
*/
|
||||
export const MAX_ALTERNATIVES = 11;
|
||||
|
||||
/** A memory size of at least this many GB (as sold) gets 3/4 of its memory as device limit, below it 2/3. */
|
||||
const LARGE_LIMIT_FROM_GB = 36;
|
||||
|
||||
@@ -808,6 +808,45 @@ export async function removePartialModelBlobs(paths?: LocalModelPaths): Promise<
|
||||
return freed;
|
||||
}
|
||||
|
||||
/** How many files of the blob folder are counted at most: a store holds a few dozen, and a walk must end. */
|
||||
const STORE_MEASURE_MAX_FILES = 20_000;
|
||||
|
||||
/**
|
||||
* What the model store takes on disk: the sizes of the regular files directly in
|
||||
* `<models>/blobs`, summed. That folder is flat (one file per layer, plus the partial files of
|
||||
* a download), so this is the store's real disk use, shared layers counted once, an
|
||||
* interrupted download included. The manifests beside it are a few kilobytes and are left out.
|
||||
*
|
||||
* One `readdir` and one `lstat` per file, at most `STORE_MEASURE_MAX_FILES` of them. Nothing is
|
||||
* followed: a `root`, a model store or a blob folder that is a symbolic link counts as empty,
|
||||
* and so does a link in the place of a file. Never outside `LocalModelPaths.root`. Resolves 0
|
||||
* when there is no store. Never throws.
|
||||
*/
|
||||
export async function measureModelStore(paths?: LocalModelPaths): Promise<number> {
|
||||
let total = 0;
|
||||
try {
|
||||
const resolved = paths ?? localModelPaths(OLLAMA_RUNTIME_PIN.version);
|
||||
const blobs = join(resolved.modelsDir, 'blobs');
|
||||
if (!isInsideLocalModelRoot(blobs, resolved.root)) return 0;
|
||||
for (const folder of [resolved.root, resolved.modelsDir, blobs]) {
|
||||
const info = await lstat(folder);
|
||||
if (info.isSymbolicLink() || !info.isDirectory()) return 0;
|
||||
}
|
||||
const names = await readdir(blobs);
|
||||
for (const name of names.slice(0, STORE_MEASURE_MAX_FILES)) {
|
||||
try {
|
||||
const info = await lstat(join(blobs, name));
|
||||
if (info.isFile()) total += info.size;
|
||||
} catch {
|
||||
// Gone between the listing and the look: a download or a delete is under way.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No model store yet.
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ---------- Port ----------
|
||||
|
||||
/** Whether nothing holds `port` on 127.0.0.1, decided by binding it and letting go again. */
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type LocalModelErrorCode,
|
||||
type LocalModelInstallState,
|
||||
type LocalModelInstallStep,
|
||||
type LocalModelLibraryEntry,
|
||||
type LocalModelStateFile,
|
||||
} from '../types.js';
|
||||
import { OLLAMA_RUNTIME_PIN } from './runtime.js';
|
||||
@@ -153,6 +154,41 @@ function parseConnection(value: unknown): LocalModelStateFile['connection'] {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** How many checked models a state file may list: far more than one Mac holds, and a bound on what a damaged file can cost. */
|
||||
const MAX_LIBRARY_ENTRIES = 64;
|
||||
|
||||
/**
|
||||
* The checked models of a file. An entry without its id, label, context or digest is dropped
|
||||
* (it could never be switched to), and so is a second entry for an id. `undefined` for a file
|
||||
* that has no list, which is what an earlier build wrote.
|
||||
*/
|
||||
function parseLibrary(value: unknown): LocalModelLibraryEntry[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const entries: LocalModelLibraryEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (entries.length >= MAX_LIBRARY_ENTRIES) break;
|
||||
if (!isRecord(item)) continue;
|
||||
const id = text(item.id, 200);
|
||||
const label = text(item.label, 200);
|
||||
const context = count(item.context);
|
||||
const digest = text(item.digest, 200);
|
||||
if (!id || !label || !context || !digest || entries.some((entry) => entry.id === id)) continue;
|
||||
const bytes = count(item.bytes);
|
||||
const cli = parseCli(item.cli);
|
||||
const verifiedAt = text(item.verifiedAt, 64);
|
||||
entries.push({
|
||||
id,
|
||||
label,
|
||||
context,
|
||||
digest,
|
||||
...(bytes ? { bytes } : {}),
|
||||
...(cli ? { cli } : {}),
|
||||
...(verifiedAt ? { verifiedAt } : {}),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state a file's text stands for. Pure and total: text that is not a JSON object of
|
||||
* `schema: 1` with a known `state` is `IDLE_LOCAL_MODEL_STATE`. Members it does not know are
|
||||
@@ -180,6 +216,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
|
||||
const connection = parseConnection(parsed.connection);
|
||||
const updatedAt = text(parsed.updatedAt, 64);
|
||||
const replacedModelId = text(parsed.replacedModelId, 200);
|
||||
const library = parseLibrary(parsed.library);
|
||||
if (!model || !cli) return IDLE_LOCAL_MODEL_STATE;
|
||||
|
||||
if (state === 'installed') {
|
||||
@@ -192,6 +229,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
|
||||
cli,
|
||||
runtime,
|
||||
...(loaded ? { loaded } : {}),
|
||||
...(library ? { library } : {}),
|
||||
...(connection ? { connection } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
};
|
||||
@@ -215,6 +253,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
|
||||
...(runtime ? { runtime } : {}),
|
||||
...(progress ? { progress } : {}),
|
||||
...(loaded ? { loaded } : {}),
|
||||
...(library ? { library } : {}),
|
||||
...(connection ? { connection } : {}),
|
||||
...(replacedModelId ? { replacedModelId } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
PiConfig,
|
||||
GrokConfig,
|
||||
CopilotConfig,
|
||||
JcodeConfig,
|
||||
DeepSeekConfig,
|
||||
OmpConfig,
|
||||
SessionRemote,
|
||||
@@ -127,6 +128,7 @@ export interface CreateSessionOptions {
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
jcodeConfig?: JcodeConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
@@ -179,6 +181,7 @@ export interface RespawnPaneOptions {
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
jcodeConfig?: JcodeConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
|
||||
@@ -85,3 +85,36 @@ export function collectDescendants(
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The descendants a session kill may signal when the pane's CLI runs a SHARED daemon
|
||||
* (`capabilities.sharedDaemon`): only those still in the pane's own process group.
|
||||
*
|
||||
* Such a CLI's first client starts a per-user daemon that every other session of that
|
||||
* CLI talks to, and the daemon detaches itself into its own session (setsid), which also
|
||||
* makes it the leader of a new process group. It stays a descendant of that first pane
|
||||
* by parent pid, though, so the plain descendant kill took it down with the pane and cut
|
||||
* every other session off (measured with jcode: deleting the tab that happened to start
|
||||
* the daemon left the next tab's client reconnecting forever). A process that left the
|
||||
* pane's group did so on purpose, and its whole subtree inherits the new group, so the
|
||||
* group is what draws the line.
|
||||
*
|
||||
* A pid whose group could not be read is KEPT in the kill list: it most likely exited
|
||||
* between the walk and the lookup, and the kill path already checks liveness before it
|
||||
* signals. Callers that could not read the groups at all keep the full list, which is
|
||||
* the behaviour before this existed.
|
||||
*
|
||||
* @param descendants the walk's result for the pane pid
|
||||
* @param groupOf pgid per pid, as read once by the caller
|
||||
* @param paneGroup the pane process's own pgid
|
||||
*/
|
||||
export function descendantsInPaneGroup(
|
||||
descendants: readonly number[],
|
||||
groupOf: ReadonlyMap<number, number>,
|
||||
paneGroup: number
|
||||
): number[] {
|
||||
return descendants.filter((pid) => {
|
||||
const group = groupOf.get(pid);
|
||||
return group === undefined || group === paneGroup;
|
||||
});
|
||||
}
|
||||
+177
-34
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* @fileoverview Auto-compact, auto-clear, and auto-resume automation for Session.
|
||||
*
|
||||
* Monitors token counts and triggers /compact or /clear commands when
|
||||
* configurable thresholds are reached. Waits for Claude to be idle
|
||||
* before sending commands, with retry logic and mutual exclusion
|
||||
* (compact and clear never run simultaneously).
|
||||
* Monitors the context size and triggers /compact or /clear commands when
|
||||
* configurable thresholds are reached. The size comes from the statusline report
|
||||
* when the session has one and from the session's token counter otherwise (see
|
||||
* decideAutoThreshold). Waits for Claude to be idle before sending commands, with
|
||||
* retry logic and mutual exclusion (compact and clear never run simultaneously),
|
||||
* and decides again when the command would go out (see _decideAtSend): nothing is
|
||||
* sent into a context that fell during the wait or into a usage-limit pause.
|
||||
*
|
||||
* Also implements auto-resume on usage limit ("token pause" control):
|
||||
* when enabled and Claude stops on a usage-limit message ("5-hour limit
|
||||
@@ -114,8 +117,8 @@ const RESUME_PROMPT = 'continue';
|
||||
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
|
||||
const MIN_AUTO_THRESHOLD = 1000;
|
||||
|
||||
/** Maximum valid threshold for auto-clear/compact (500k tokens) */
|
||||
const MAX_AUTO_THRESHOLD = 500_000;
|
||||
/** Maximum valid threshold for auto-clear/compact when the model's window is not reported (1M tokens) */
|
||||
const MAX_AUTO_THRESHOLD = 1_000_000;
|
||||
|
||||
/** Default auto-clear threshold when invalid value provided */
|
||||
const DEFAULT_AUTO_CLEAR_THRESHOLD = 140_000;
|
||||
@@ -123,6 +126,61 @@ const DEFAULT_AUTO_CLEAR_THRESHOLD = 140_000;
|
||||
/** Default auto-compact threshold when invalid value provided */
|
||||
const DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000;
|
||||
|
||||
/** The context size Claude Code last reported on its statusline. */
|
||||
export interface ContextReport {
|
||||
/** Tokens in the context window at the last API response, cache reads and writes included. */
|
||||
tokens: number;
|
||||
/** The model's window (200k, or 1M with extended context); absent when not reported. */
|
||||
windowSize?: number;
|
||||
}
|
||||
|
||||
export interface AutoThresholdInput {
|
||||
threshold: number;
|
||||
/** The newest statusline report, or null when none has arrived. */
|
||||
report: ContextReport | null;
|
||||
/** The newest report's size when the last /compact or /clear went out, or null. */
|
||||
reportTokensAtCommand: number | null;
|
||||
/** The session's token counter. */
|
||||
counterTokens: number;
|
||||
/** The counter's value at the last /compact: it never drops by itself after one. */
|
||||
counterBaseline: number;
|
||||
}
|
||||
|
||||
export interface AutoThresholdDecision {
|
||||
trigger: boolean;
|
||||
tokens: number;
|
||||
source: 'statusline' | 'counter';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the context has reached a threshold. Pure, so every case is testable without a session.
|
||||
*
|
||||
* A report wins over the counter: the counter leaves out cache reads, so on a long conversation
|
||||
* it is a small fraction of the real context. The counter stays as the fallback, because the
|
||||
* exporter only runs for Claude, only while plan-usage collection is on, and never on a remote or
|
||||
* Docker launch or in a workspace with its own statusline.
|
||||
*
|
||||
* A report carries the usage of the last API response, so after a /compact or /clear the number
|
||||
* from before the command stays the newest one until the command finishes, and it is re-posted on
|
||||
* every render. It is ignored by VALUE rather than for a length of time: the first report that
|
||||
* differs came from the finished command (0 on Claude Code 2.1.319) or from a later API response.
|
||||
* If the command was swallowed that report is higher and the threshold fires again, so nothing
|
||||
* can lock auto-compact off, and no timer has to guess how long a compact takes (601k tokens took
|
||||
* 39 s, four times the cooldown).
|
||||
*
|
||||
* A counter summed from JSON usage never drops after a compact, so it counts from the value at
|
||||
* the last one. The pane parser's counter is absolute, see noteAbsoluteCounter().
|
||||
*/
|
||||
export function decideAutoThreshold(input: AutoThresholdInput): AutoThresholdDecision {
|
||||
const { threshold, report, reportTokensAtCommand, counterTokens, counterBaseline } = input;
|
||||
if (report) {
|
||||
const stale = report.tokens === reportTokensAtCommand;
|
||||
return { trigger: !stale && report.tokens >= threshold, tokens: report.tokens, source: 'statusline' };
|
||||
}
|
||||
const tokens = Math.max(0, counterTokens - counterBaseline);
|
||||
return { trigger: tokens >= threshold, tokens, source: 'counter' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks required by SessionAutoOps to interact with the parent Session.
|
||||
*/
|
||||
@@ -135,6 +193,8 @@ interface AutoOpsCallbacks {
|
||||
isStopped: () => boolean;
|
||||
/** Get current total token count (input + output) */
|
||||
getTotalTokens: () => number;
|
||||
/** The newest statusline context report, or null when none has arrived */
|
||||
getContextReport: () => ContextReport | null;
|
||||
/** Get session ID for logging */
|
||||
getSessionId: () => string;
|
||||
}
|
||||
@@ -156,6 +216,8 @@ export class SessionAutoOps extends EventEmitter {
|
||||
private _autoCompactEnabled: boolean = false;
|
||||
private _autoCompactPrompt: string = '';
|
||||
private _isCompacting: boolean = false;
|
||||
private _reportTokensAtCommand: number | null = null;
|
||||
private _counterBaseline: number = 0;
|
||||
private _autoCompactTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Auto-clear state
|
||||
@@ -206,14 +268,7 @@ export class SessionAutoOps extends EventEmitter {
|
||||
setAutoCompact(enabled: boolean, threshold?: number, prompt?: string): void {
|
||||
this._autoCompactEnabled = enabled;
|
||||
if (threshold !== undefined) {
|
||||
if (threshold < MIN_AUTO_THRESHOLD || threshold > MAX_AUTO_THRESHOLD) {
|
||||
console.warn(
|
||||
`[SessionAutoOps ${this.callbacks.getSessionId()}] Invalid autoCompact threshold ${threshold}, must be between ${MIN_AUTO_THRESHOLD} and ${MAX_AUTO_THRESHOLD}. Using default ${DEFAULT_AUTO_COMPACT_THRESHOLD}.`
|
||||
);
|
||||
this._autoCompactThreshold = DEFAULT_AUTO_COMPACT_THRESHOLD;
|
||||
} else {
|
||||
this._autoCompactThreshold = threshold;
|
||||
}
|
||||
this._autoCompactThreshold = this._resolveThreshold('autoCompact', threshold, DEFAULT_AUTO_COMPACT_THRESHOLD);
|
||||
}
|
||||
if (prompt !== undefined) {
|
||||
this._autoCompactPrompt = prompt;
|
||||
@@ -239,14 +294,7 @@ export class SessionAutoOps extends EventEmitter {
|
||||
setAutoClear(enabled: boolean, threshold?: number): void {
|
||||
this._autoClearEnabled = enabled;
|
||||
if (threshold !== undefined) {
|
||||
if (threshold < MIN_AUTO_THRESHOLD || threshold > MAX_AUTO_THRESHOLD) {
|
||||
console.warn(
|
||||
`[SessionAutoOps ${this.callbacks.getSessionId()}] Invalid autoClear threshold ${threshold}, must be between ${MIN_AUTO_THRESHOLD} and ${MAX_AUTO_THRESHOLD}. Using default ${DEFAULT_AUTO_CLEAR_THRESHOLD}.`
|
||||
);
|
||||
this._autoClearThreshold = DEFAULT_AUTO_CLEAR_THRESHOLD;
|
||||
} else {
|
||||
this._autoClearThreshold = threshold;
|
||||
}
|
||||
this._autoClearThreshold = this._resolveThreshold('autoClear', threshold, DEFAULT_AUTO_CLEAR_THRESHOLD);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,29 +442,118 @@ export class SessionAutoOps extends EventEmitter {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if auto-compact should be triggered based on current token count.
|
||||
* Called after token count updates.
|
||||
* A value above the window stops at the window rather than falling to the default: a threshold
|
||||
* saved under a larger window comes back on every save of the options, and the default would
|
||||
* compact far earlier than anyone asked for.
|
||||
*/
|
||||
private _resolveThreshold(name: string, threshold: number, fallback: number): number {
|
||||
const id = this.callbacks.getSessionId();
|
||||
if (threshold < MIN_AUTO_THRESHOLD) {
|
||||
console.warn(
|
||||
`[SessionAutoOps ${id}] Invalid ${name} threshold ${threshold}, minimum is ${MIN_AUTO_THRESHOLD}. Using default ${fallback}.`
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
const max = this._maxThreshold();
|
||||
if (threshold > max) {
|
||||
console.warn(`[SessionAutoOps ${id}] ${name} threshold ${threshold} is above the window, using ${max}.`);
|
||||
return max;
|
||||
}
|
||||
return threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pane parser's counter holds the absolute number on screen and only moves past its highest
|
||||
* value so far, so a move means the context grew back past the last compact. Counting that from
|
||||
* the baseline would ask for twice the threshold, which a 200k window never reaches.
|
||||
*/
|
||||
noteAbsoluteCounter(): void {
|
||||
this._counterBaseline = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest threshold that can ever fire: the reported window, or 1M when it is unknown.
|
||||
* Never below the minimum, so a nonsense window (a report of 500) cannot store a threshold
|
||||
* that every later save would then refuse.
|
||||
*/
|
||||
private _maxThreshold(): number {
|
||||
return Math.max(MIN_AUTO_THRESHOLD, this.callbacks.getContextReport()?.windowSize ?? MAX_AUTO_THRESHOLD);
|
||||
}
|
||||
|
||||
/**
|
||||
* True from a usage-limit message until the resume prompt went out. A /clear sent in that
|
||||
* window is followed by auto-resume's `continue`, typed into an empty conversation, and a
|
||||
* /compact needs the API the limit just closed. Only auto-resume watches for the limit, so
|
||||
* with it off this is always false, as before.
|
||||
*/
|
||||
private _heldByLimit(): boolean {
|
||||
return this._limitPaused || this._resumeFollowupTimer !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision at the moment the command would go out, or null when it must not. A report
|
||||
* fires the trigger in the middle of a turn and the command then waits for that turn to end,
|
||||
* which can outlast a compaction by Claude Code itself (the context is small again), a change
|
||||
* of the option or its threshold, and the start of a usage-limit pause. The counter never
|
||||
* falls, so on the fallback this only repeats the trigger's answer.
|
||||
*
|
||||
* A null stamps nothing: the caller returns, the cooldown releases its flag, and the next
|
||||
* report is judged like any other.
|
||||
*/
|
||||
private _decideAtSend(name: string, enabled: boolean, threshold: number): AutoThresholdDecision | null {
|
||||
const skipped = (why: string): null => {
|
||||
console.log(`[SessionAutoOps ${this.callbacks.getSessionId()}] ${name} not sent: ${why}`);
|
||||
return null;
|
||||
};
|
||||
if (!enabled) return skipped('switched off while it waited for the turn to end');
|
||||
if (this._heldByLimit()) return skipped('the session is paused on a usage limit');
|
||||
const decision = this._decide(threshold);
|
||||
if (!decision.trigger) {
|
||||
return skipped(`${decision.tokens} tokens (${decision.source}) is no longer at the ${threshold} threshold`);
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
private _decide(threshold: number): AutoThresholdDecision {
|
||||
return decideAutoThreshold({
|
||||
threshold,
|
||||
report: this.callbacks.getContextReport(),
|
||||
reportTokensAtCommand: this._reportTokensAtCommand,
|
||||
counterTokens: this.callbacks.getTotalTokens(),
|
||||
counterBaseline: this._counterBaseline,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-compact should be triggered based on the current context size.
|
||||
* Called after token count updates and after each statusline report.
|
||||
*/
|
||||
checkAutoCompact(): void {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
if (!this._autoCompactEnabled || this._isCompacting || this._isClearing) return;
|
||||
if (this._heldByLimit()) return;
|
||||
|
||||
const totalTokens = this.callbacks.getTotalTokens();
|
||||
if (totalTokens >= this._autoCompactThreshold) {
|
||||
const decision = this._decide(this._autoCompactThreshold);
|
||||
if (decision.trigger) {
|
||||
this._isCompacting = true;
|
||||
console.log(
|
||||
`[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`
|
||||
`[SessionAutoOps] Auto-compact triggered: ${decision.tokens} tokens (${decision.source}) >= ${this._autoCompactThreshold} threshold`
|
||||
);
|
||||
|
||||
const action = async () => {
|
||||
const atSend = this._decideAtSend('Auto-compact', this._autoCompactEnabled, this._autoCompactThreshold);
|
||||
if (!atSend) return;
|
||||
// One line: this fires unattended, and a multi-line focus prompt would go out
|
||||
// as a multi-line paste (see toSingleLine).
|
||||
const compactCmd = this._autoCompactPrompt
|
||||
? `/compact ${toSingleLine(this._autoCompactPrompt)}\r`
|
||||
: '/compact\r';
|
||||
// Read here, not at the trigger: the idle wait can outlast several reports.
|
||||
this._reportTokensAtCommand = this.callbacks.getContextReport()?.tokens ?? null;
|
||||
this._counterBaseline = this.callbacks.getTotalTokens();
|
||||
await this.callbacks.writeCommand(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
tokens: atSend.tokens,
|
||||
threshold: this._autoCompactThreshold,
|
||||
prompt: this._autoCompactPrompt || undefined,
|
||||
});
|
||||
@@ -446,23 +583,29 @@ export class SessionAutoOps extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-clear should be triggered based on current token count.
|
||||
* Called after token count updates.
|
||||
* Check if auto-clear should be triggered based on the current context size.
|
||||
* Called after token count updates and after each statusline report.
|
||||
*/
|
||||
checkAutoClear(): void {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
if (!this._autoClearEnabled || this._isClearing || this._isCompacting) return;
|
||||
if (this._heldByLimit()) return;
|
||||
|
||||
const totalTokens = this.callbacks.getTotalTokens();
|
||||
if (totalTokens >= this._autoClearThreshold) {
|
||||
const decision = this._decide(this._autoClearThreshold);
|
||||
if (decision.trigger) {
|
||||
this._isClearing = true;
|
||||
console.log(
|
||||
`[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`
|
||||
`[SessionAutoOps] Auto-clear triggered: ${decision.tokens} tokens (${decision.source}) >= ${this._autoClearThreshold} threshold`
|
||||
);
|
||||
|
||||
const action = async () => {
|
||||
const atSend = this._decideAtSend('Auto-clear', this._autoClearEnabled, this._autoClearThreshold);
|
||||
if (!atSend) return;
|
||||
this._reportTokensAtCommand = this.callbacks.getContextReport()?.tokens ?? null;
|
||||
// The session zeroes its counter on 'autoClear', so the counter restarts from 0.
|
||||
this._counterBaseline = 0;
|
||||
await this.callbacks.writeCommand('/clear\r');
|
||||
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
|
||||
this.emit('autoClear', { tokens: atSend.tokens, threshold: this._autoClearThreshold });
|
||||
};
|
||||
|
||||
if (!this.callbacks.isStopped()) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
GeminiConfig,
|
||||
GrokConfig,
|
||||
CopilotConfig,
|
||||
JcodeConfig,
|
||||
OmpConfig,
|
||||
OpenCodeConfig,
|
||||
PiConfig,
|
||||
@@ -53,6 +54,7 @@ export interface SpawnBridgeOptions {
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
jcodeConfig?: JcodeConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
|
||||
+35
-6
@@ -58,6 +58,7 @@ import {
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type CopilotConfig,
|
||||
type JcodeConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
@@ -153,7 +154,7 @@ import {
|
||||
buildMuxAttachEnv,
|
||||
buildShellEnv,
|
||||
} from './session-cli-builder.js';
|
||||
import { SessionAutoOps } from './session-auto-ops.js';
|
||||
import { SessionAutoOps, type ContextReport } from './session-auto-ops.js';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { SessionTaskCache } from './session-task-cache.js';
|
||||
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
|
||||
@@ -675,6 +676,7 @@ export class Session extends EventEmitter {
|
||||
private _watching: string | null = null;
|
||||
/** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */
|
||||
private _promptCache: PromptCacheStatus | null = null;
|
||||
private _contextReport: ContextReport | null = null;
|
||||
/** Prompt-cache keep-warm opt-in; undefined = follow the app-wide default. Persisted. */
|
||||
private _keepWarmEnabled: boolean | undefined;
|
||||
/** Last status the keep-warm service published for this session; ephemeral. */
|
||||
@@ -864,6 +866,8 @@ export class Session extends EventEmitter {
|
||||
private _grokConfig: GrokConfig | undefined;
|
||||
// GitHub Copilot configuration (only for mode === 'copilot')
|
||||
private _copilotConfig: CopilotConfig | undefined;
|
||||
// jcode configuration (only for mode === 'jcode')
|
||||
private _jcodeConfig: JcodeConfig | undefined;
|
||||
|
||||
// DeepSeek Harness configuration (only for mode === 'deepseek')
|
||||
private _deepSeekConfig: DeepSeekConfig | undefined;
|
||||
@@ -1003,6 +1007,8 @@ export class Session extends EventEmitter {
|
||||
/** Grok configuration (only for mode === 'grok') */
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
/** jcode configuration (only for mode === 'jcode') */
|
||||
jcodeConfig?: JcodeConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP configuration (only for mode === 'omp') */
|
||||
@@ -1197,6 +1203,11 @@ export class Session extends EventEmitter {
|
||||
this._copilotConfig = config.copilotConfig;
|
||||
}
|
||||
|
||||
// Apply jcode configuration
|
||||
if (config.jcodeConfig) {
|
||||
this._jcodeConfig = config.jcodeConfig;
|
||||
}
|
||||
|
||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
||||
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
||||
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
||||
@@ -1290,6 +1301,7 @@ export class Session extends EventEmitter {
|
||||
isWorking: () => this._isWorking,
|
||||
isStopped: () => this._isStopped,
|
||||
getTotalTokens: () => this._totalInputTokens + this._totalOutputTokens,
|
||||
getContextReport: () => this._contextReport,
|
||||
getSessionId: () => this.id,
|
||||
});
|
||||
this._autoOps.on('autoCompact', (data) => this.emit('autoCompact', data));
|
||||
@@ -1485,6 +1497,7 @@ export class Session extends EventEmitter {
|
||||
// rather than show the previous conversation's "warm until" against the new one; the
|
||||
// next statusline render repopulates. A same-id reattach returned above, untouched.
|
||||
this._promptCache = null;
|
||||
this._contextReport = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1833,6 +1846,17 @@ export class Session extends EventEmitter {
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the context size from a statusline report and run the threshold checks on it. The
|
||||
* token paths above cannot do this: they only run when the counter moves, and the counter
|
||||
* does not see the cached context.
|
||||
*/
|
||||
noteContextReport(report: ContextReport): void {
|
||||
this._contextReport = report;
|
||||
this._autoOps.checkAutoCompact();
|
||||
this._autoOps.checkAutoClear();
|
||||
}
|
||||
|
||||
/** Prompt-cache keep-warm opt-in; undefined means the app-wide default applies. */
|
||||
get keepWarmEnabled(): boolean | undefined {
|
||||
return this._keepWarmEnabled;
|
||||
@@ -2479,6 +2503,7 @@ export class Session extends EventEmitter {
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
promptCache: this._promptCache ?? undefined,
|
||||
contextWindowSize: this._contextReport?.windowSize,
|
||||
statusLineExporter: this.statusLineExporter,
|
||||
keepWarmEnabled: this._keepWarmEnabled,
|
||||
keepWarm: this._keepWarm ?? undefined,
|
||||
@@ -2512,6 +2537,7 @@ export class Session extends EventEmitter {
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
jcodeConfig: this._jcodeConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
@@ -2893,6 +2919,7 @@ export class Session extends EventEmitter {
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
jcodeConfig: this._jcodeConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
// OMP resolution/pinning does NOT happen here. This object is built
|
||||
// EAGERLY — including on every boot-recovery reattach, before anyone
|
||||
@@ -3411,6 +3438,7 @@ export class Session extends EventEmitter {
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
jcodeConfig: this._jcodeConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
@@ -3994,6 +4022,7 @@ export class Session extends EventEmitter {
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
jcodeConfig: this._jcodeConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
};
|
||||
@@ -5006,9 +5035,9 @@ export class Session extends EventEmitter {
|
||||
// Matches patterns like "123.4k tokens", "5234 tokens", "1.2M tokens"
|
||||
//
|
||||
// SAFETY LIMITS:
|
||||
// - Max tokens per session: 500k (Claude's context is ~200k)
|
||||
// - Max tokens per session: MAX_SESSION_TOKENS (the largest context Claude reports is 1M)
|
||||
// - Max delta per update: 100k (prevents sudden jumps from parsing errors)
|
||||
// - Rejects "M" suffix values > 0.5 (500k) to prevent false matches
|
||||
// - Rejects "M" suffix values above that maximum to prevent false matches
|
||||
private parseTokensFromStatusLine(cleanData: string): void {
|
||||
// Quick pre-check: skip expensive regex if "token" not present (performance optimization)
|
||||
if (!cleanData.includes('token')) return;
|
||||
@@ -5026,9 +5055,8 @@ export class Session extends EventEmitter {
|
||||
if (suffix === 'k') {
|
||||
tokenCount *= 1000;
|
||||
} else if (suffix === 'm') {
|
||||
// Safety: Reject M values that would result in > 500k tokens
|
||||
// Claude's context window is ~200k, so anything claiming millions is likely a false match
|
||||
if (tokenCount > 0.5) {
|
||||
// Safety: anything claiming more than the largest context is likely a false match
|
||||
if (tokenCount * 1000000 > MAX_SESSION_TOKENS) {
|
||||
console.warn(
|
||||
`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`
|
||||
);
|
||||
@@ -5064,6 +5092,7 @@ export class Session extends EventEmitter {
|
||||
this._totalOutputTokens += Math.round(delta * 0.4);
|
||||
|
||||
// Check if we should auto-compact or auto-clear
|
||||
this._autoOps.noteAbsoluteCounter();
|
||||
this._autoOps.checkAutoCompact();
|
||||
this._autoOps.checkAutoClear();
|
||||
}
|
||||
|
||||
+50
-4
@@ -22,7 +22,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { collectDescendants } from './proc-tree.js';
|
||||
import { collectDescendants, descendantsInPaneGroup } from './proc-tree.js';
|
||||
import {
|
||||
foreignViewSessionName,
|
||||
buildForeignDockerViewKillCommand,
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type CopilotConfig,
|
||||
type JcodeConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
@@ -1213,6 +1214,7 @@ export function buildSpawnCommand(options: {
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
jcodeConfig?: JcodeConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
@@ -2563,6 +2565,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
jcodeConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -2662,6 +2665,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
jcodeConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -2931,6 +2935,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
jcodeConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -2982,6 +2987,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
jcodeConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -3160,6 +3166,45 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a pane's descendants to the ones a kill may signal, for a CLI that runs a
|
||||
* shared daemon (`capabilities.sharedDaemon`): those still in the pane's process group
|
||||
* (see `descendantsInPaneGroup()`). One bounded `ps` for the pane and its descendants,
|
||||
* as an argv (no shell). Any other CLI, or a lookup that failed, gets the list back
|
||||
* unchanged, which is the kill as it always was.
|
||||
*/
|
||||
private async killableDescendants(mode: SessionMode, panePid: number, descendants: number[]): Promise<number[]> {
|
||||
if (descendants.length === 0 || !getCli(mode)?.capabilities.sharedDaemon) return descendants;
|
||||
try {
|
||||
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
|
||||
execFile(
|
||||
'ps',
|
||||
['-o', 'pid=,pgid=', '-p', [panePid, ...descendants].join(',')],
|
||||
{ timeout: EXEC_TIMEOUT_MS },
|
||||
// `ps -p` exits non-zero when some pid is already gone; what it printed still counts.
|
||||
(err, out) => (out ? resolve({ stdout: out }) : reject(err ?? new Error('ps printed nothing')))
|
||||
);
|
||||
});
|
||||
const groupOf = new Map<number, number>();
|
||||
for (const line of stdout.split('\n')) {
|
||||
const [pid, pgid] = line.trim().split(/\s+/).map(Number);
|
||||
if (Number.isInteger(pid) && Number.isInteger(pgid)) groupOf.set(pid, pgid);
|
||||
}
|
||||
const paneGroup = groupOf.get(panePid);
|
||||
if (paneGroup === undefined) return descendants;
|
||||
const killable = descendantsInPaneGroup(descendants, groupOf, paneGroup);
|
||||
if (killable.length < descendants.length) {
|
||||
console.log(
|
||||
`[TmuxManager] Sparing ${descendants.length - killable.length} process(es) outside pane ${panePid}'s group (the CLI's shared daemon)`
|
||||
);
|
||||
}
|
||||
return killable;
|
||||
} catch (err) {
|
||||
console.warn(`[TmuxManager] Could not read process groups for pane ${panePid}; killing every descendant:`, err);
|
||||
return descendants;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a process is still alive. Signal decisions only: an unreaped zombie
|
||||
// counts here, so its process group still gets the SIGKILL below. The WAITS use
|
||||
// waitForProcessesExit(), which counts a zombie as exited.
|
||||
@@ -3308,8 +3353,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
const allPids: number[] = [currentPid];
|
||||
|
||||
// Strategy 1: Kill all child processes recursively
|
||||
let childPids = await this.getChildPidsFresh(currentPid);
|
||||
// Strategy 1: Kill all child processes recursively (but never a shared daemon the
|
||||
// pane's CLI started for every session of that CLI: see killableDescendants)
|
||||
let childPids = await this.killableDescendants(session.mode, currentPid, await this.getChildPidsFresh(currentPid));
|
||||
if (childPids.length > 0) {
|
||||
console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`);
|
||||
allPids.push(...childPids);
|
||||
@@ -3328,7 +3374,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// catch anything spawned since the first one.
|
||||
await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS });
|
||||
|
||||
childPids = await this.getChildPidsFresh(currentPid);
|
||||
childPids = await this.killableDescendants(session.mode, currentPid, await this.getChildPidsFresh(currentPid));
|
||||
for (const childPid of childPids) {
|
||||
if (this.isProcessAlive(childPid)) {
|
||||
try {
|
||||
|
||||
@@ -1062,6 +1062,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string
|
||||
{ id: 'pi', label: 'pi', detail: 'pi.dev' },
|
||||
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' },
|
||||
{ id: 'deepseek', label: 'deepseek', detail: 'DeepSeek Harness (dsh)' },
|
||||
{ id: 'jcode', label: 'jcode', detail: 'jcode' },
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -155,7 +155,7 @@ export type TuiAnswerResult =
|
||||
|
||||
export interface TuiQuickStartOptions {
|
||||
caseName: string;
|
||||
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek';
|
||||
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'jcode';
|
||||
sessionName?: string;
|
||||
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
|
||||
parentSessionId?: string;
|
||||
|
||||
@@ -115,6 +115,12 @@ export interface FileWriteData {
|
||||
eol: 'lf' | 'crlf';
|
||||
}
|
||||
|
||||
/** Response payload for `DELETE /api/sessions/:id/files?path=` (the Files panel's delete button). */
|
||||
export interface FileDeleteData {
|
||||
/** The path as submitted (workspace-relative, or absolute inside the session's outside worktree) */
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
* | `GET /api/local-model` | none | none (a failed probe is an answer) |
|
||||
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers, or a saved endpoint |
|
||||
* | | | already has the reserved id; 409 `job-running`, |
|
||||
* | | | `already-installed`, `size-changed` |
|
||||
* | | | `already-installed` (this model at this context is |
|
||||
* | | | the one in use), `size-changed`, `sessions-open` |
|
||||
* | `POST /api/local-model/use` | `LocalModelUseRequest` | 400 not a checked model of the library, or no coding |
|
||||
* | | | agent for it is installed; 409 `job-running`, |
|
||||
* | | | `wrong-state`, `already-installed`, `sessions-open` |
|
||||
* | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) |
|
||||
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or |
|
||||
* | | | failed), `sessions-open` (another port while live |
|
||||
@@ -33,6 +37,9 @@
|
||||
* | | | `sessions-open` (stop, or another port, while live |
|
||||
* | | | sessions use the model) |
|
||||
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` |
|
||||
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | 400 not a model of this Mac; 409 `job-running`, |
|
||||
* | | | `wrong-state`, `model-active`, `runtime-not-running`; |
|
||||
* | | | 500 the runtime did not delete it |
|
||||
*
|
||||
* A 409 is the error envelope plus `data: LocalModelConflict`:
|
||||
* `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409
|
||||
@@ -141,7 +148,10 @@ export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [
|
||||
* | `harness-failed` | the coding agent's install command failed, or its binary is still missing |
|
||||
* | `endpoint-id-taken` | a saved endpoint with our id exists that this feature did not write |
|
||||
* | `connect-failed` | the endpoint record or the settings flag could not be written |
|
||||
* | `model-changed` | installed: the model's digest is not the one stored at install time |
|
||||
* | `model-changed` | installed: the model's digest is not the one stored at install time. A |
|
||||
* | | switch: the model's digest is not the one it was checked with |
|
||||
* | `job-unfinished` | a job that changes the model in use is running or stopped. Only a |
|
||||
* | | refused session start says it: nothing is started until the job ended |
|
||||
* | `internal` | anything unexpected (the exception text is in `detail`) |
|
||||
*/
|
||||
export type LocalModelErrorCode =
|
||||
@@ -164,6 +174,7 @@ export type LocalModelErrorCode =
|
||||
| 'endpoint-id-taken'
|
||||
| 'connect-failed'
|
||||
| 'model-changed'
|
||||
| 'job-unfinished'
|
||||
| 'internal';
|
||||
|
||||
/**
|
||||
@@ -192,12 +203,28 @@ export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start'
|
||||
* - `cancel`: `POST /api/local-model/cancel`.
|
||||
* - `resume`: `POST /api/local-model/resume`.
|
||||
* - `start` and `stop`: `POST /api/local-model/runtime`.
|
||||
* - `remove`: `DELETE /api/local-model`.
|
||||
* - `remove`: `DELETE /api/local-model`. In `idle` it is listed only while the model store
|
||||
* still holds something (models an earlier Remove kept): it is then what frees that space.
|
||||
* - `start-session`: not a route of this feature: the window starts a session on the endpoint
|
||||
* `endpointId` with the coding agent `cli`. Offered only while the runtime runs and nothing
|
||||
* blocks a session.
|
||||
* - `use`: `POST /api/local-model/use`. Listed only while a row of `library` offers it.
|
||||
* - `delete-model`: `DELETE /api/local-model/models`. Listed only while a row of `library`
|
||||
* offers it.
|
||||
* - `install-agent`: `POST /api/local-model/agent`. Listed only while a row of `harnesses`
|
||||
* offers it.
|
||||
*/
|
||||
export type LocalModelAction = 'install' | 'cancel' | 'resume' | 'start' | 'stop' | 'remove' | 'start-session';
|
||||
export type LocalModelAction =
|
||||
| 'install'
|
||||
| 'cancel'
|
||||
| 'resume'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'remove'
|
||||
| 'start-session'
|
||||
| 'use'
|
||||
| 'delete-model'
|
||||
| 'install-agent';
|
||||
|
||||
/** Whether the Mac has the developer tools (git, Python) a coding agent calls. */
|
||||
export type LocalModelDeveloperTools =
|
||||
@@ -225,7 +252,9 @@ export type LocalModelDeveloperTools =
|
||||
* | `errHarnessFailed` | `cli`: the coding agent's label |
|
||||
* | `errEndpointIdTaken` | `id`: the endpoint id this feature uses |
|
||||
* | `refusedAgentNotInstallable` | `cli`: the coding agent's label |
|
||||
* | `refusedOtherModel` | `model`: the label of the model that is installed |
|
||||
* | `refusedOtherModel` | `model`: the label of the model that is in use |
|
||||
* | `refusedAgentMissing` | `cli`: the coding agent's label |
|
||||
* | `conflictModelActive`, `deleteFailed` | `model`: the model's label |
|
||||
* | `conflictSizeChanged` | `size`: the server's own total as decimal GB |
|
||||
* | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' |
|
||||
*
|
||||
@@ -259,16 +288,23 @@ export const LOCAL_MODEL_INSTALL_LABELS = {
|
||||
'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.',
|
||||
errConnectFailed: 'Codeman could not save the connection to the model.',
|
||||
errModelChanged: 'The model on this Mac changed since it was installed. Remove it and install it again.',
|
||||
errJobUnfinished:
|
||||
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.',
|
||||
errInternal: 'The install stopped on an unexpected error.',
|
||||
conflictJobRunning: 'An install is already running.',
|
||||
conflictAlreadyInstalled: 'A model is already installed on this Mac. Remove it first.',
|
||||
conflictAlreadyInstalled: 'This model is already in use on this Mac.',
|
||||
conflictModelActive: '{model} is the model in use. Switch to another model first, or remove the local model.',
|
||||
conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.',
|
||||
conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.',
|
||||
conflictWrongState: 'That is not possible in the current state.',
|
||||
refusedModelNotOffered: 'This model at this context is not offered for this Mac right now.',
|
||||
refusedAgentNotOffered: 'This coding agent is not offered for this model.',
|
||||
refusedAgentNotInstallable: '{cli} is not installed and cannot be installed from here.',
|
||||
refusedOtherModel: 'Only {model} is installed on this Mac. Remove it and install another model first.',
|
||||
refusedOtherModel: 'Only {model} is in use on this Mac. Switch models in the local model window first.',
|
||||
refusedNotInLibrary: 'This model is not one of the checked models on this Mac.',
|
||||
refusedNotOnThisMac: 'This model is not on this Mac.',
|
||||
deleteFailed: '{model} could not be deleted from this Mac.',
|
||||
refusedAgentMissing: '{cli} is not installed on this Mac. Install a coding agent in the local model window first.',
|
||||
endpointManaged:
|
||||
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
|
||||
endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
|
||||
@@ -307,6 +343,7 @@ export const LOCAL_MODEL_ERROR_LABELS: Record<LocalModelErrorCode, LocalModelIns
|
||||
'endpoint-id-taken': LOCAL_MODEL_INSTALL_LABELS.errEndpointIdTaken,
|
||||
'connect-failed': LOCAL_MODEL_INSTALL_LABELS.errConnectFailed,
|
||||
'model-changed': LOCAL_MODEL_INSTALL_LABELS.errModelChanged,
|
||||
'job-unfinished': LOCAL_MODEL_INSTALL_LABELS.errJobUnfinished,
|
||||
internal: LOCAL_MODEL_INSTALL_LABELS.errInternal,
|
||||
};
|
||||
|
||||
@@ -331,6 +368,7 @@ export const LOCAL_MODEL_ERROR_NEXT: Record<LocalModelErrorCode, LocalModelNextS
|
||||
'endpoint-id-taken': 'retry',
|
||||
'connect-failed': 'retry',
|
||||
'model-changed': 'remove',
|
||||
'job-unfinished': 'retry',
|
||||
internal: 'retry',
|
||||
};
|
||||
|
||||
@@ -426,7 +464,9 @@ export interface LocalModelLoadedFacts {
|
||||
* | `port` | | once chosen | once chosen | once chosen | yes |
|
||||
* | `loaded`, `endpointId` | | | | | yes |
|
||||
*
|
||||
* `running`, `developerTools`, `runtime`, `folder` and `actions` are always present.
|
||||
* `running`, `developerTools`, `runtime`, `folder`, `library`, `harnesses` and `actions` are
|
||||
* always present. `store` is present once the model store was measured. `harnesses` is empty
|
||||
* in every state but `installed`.
|
||||
*/
|
||||
export interface LocalModelInstallView {
|
||||
state: LocalModelInstallState;
|
||||
@@ -437,7 +477,11 @@ export interface LocalModelInstallView {
|
||||
progress?: LocalModelProgress;
|
||||
error?: LocalModelInstallError;
|
||||
model?: LocalModelInstalledModel;
|
||||
/** The coding agent: a CLI registry id, as data. */
|
||||
/**
|
||||
* The coding agent of the job, and on an installed model the DEFAULT one: the agent it was
|
||||
* installed with, which a client that names no agent starts. A CLI registry id, as data.
|
||||
* `harnesses` lists every agent a session can be started with.
|
||||
*/
|
||||
cli?: string;
|
||||
/** The coding agent's display label ('OpenCode'). */
|
||||
cliLabel?: string;
|
||||
@@ -454,10 +498,127 @@ export interface LocalModelInstallView {
|
||||
loaded?: LocalModelLoadedFacts;
|
||||
/** The id of the saved endpoint a session is started on. */
|
||||
endpointId?: string;
|
||||
/**
|
||||
* The models on this Mac: every model Codeman checked (`LocalModelStateFile.library`), plus
|
||||
* whatever else the running runtime lists. Empty in `idle` (no runtime is there to ask) and
|
||||
* before anything was downloaded. The model in use comes first.
|
||||
*/
|
||||
library: LocalModelLibraryRow[];
|
||||
/** What the model store takes on disk right now. Present in every state once it was measured. */
|
||||
store?: LocalModelStoreView;
|
||||
/**
|
||||
* The coding agents a session on the model in use can be started with: every catalogue
|
||||
* harness that works at the model's context and accepts its name, the default one (`cli`)
|
||||
* first, then by catalogue rank. The agent is chosen per session, so a row is not a setting.
|
||||
* Empty in every state but `installed`.
|
||||
*/
|
||||
harnesses: LocalModelHarnessRow[];
|
||||
/** What the server accepts right now, see `LocalModelAction`. */
|
||||
actions: LocalModelAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a coding agent of `harnesses` stands on this Mac.
|
||||
* - `ready`: it can be started (the same answer the Run menu gets).
|
||||
* - `installable`: it is not on this Mac, and `POST /api/local-model/agent` installs it with
|
||||
* `installCommand`.
|
||||
* - `installing`: that install is running. It runs beside the model: nothing about the model
|
||||
* in use changes, and a session with another agent can be started meanwhile.
|
||||
* - `setup-needed`: it is not startable and one command does not make it so (a launcher
|
||||
* without a profile, or a command that needs an administrator). Its own setup is in the Run
|
||||
* menu or in a terminal; nothing is offered here.
|
||||
*/
|
||||
export type LocalModelHarnessStatus = 'ready' | 'installable' | 'installing' | 'setup-needed';
|
||||
|
||||
/** What can be done with one row of `harnesses`: start a session with it, or `POST /api/local-model/agent`. */
|
||||
export type LocalModelHarnessAction = 'start-session' | 'install';
|
||||
|
||||
/** One coding agent the model in use can be run with. Everything a client needs is here, so it compares no CLI id. */
|
||||
export interface LocalModelHarnessRow {
|
||||
/** The CLI registry id, as data: the `mode` of a session started with it, and the `cli` of `POST /api/local-model/agent`. */
|
||||
cli: string;
|
||||
/** The registry's display label ('OpenCode'). */
|
||||
cliLabel: string;
|
||||
/** True on the row `LocalModelInstallView.cli` names. */
|
||||
default: boolean;
|
||||
status: LocalModelHarnessStatus;
|
||||
/**
|
||||
* The literal command `POST /api/local-model/agent` would run: the STOCK command of the CLI
|
||||
* registry for this platform. Present exactly when `status` is `installable` or `installing`.
|
||||
* The consent prints it as it is.
|
||||
*/
|
||||
installCommand?: string;
|
||||
/** The harness row's `slowFirstTurn`: the first answer takes minutes. */
|
||||
slowFirstTurn?: boolean;
|
||||
/**
|
||||
* A literal with no white space the agent's composer draws once it takes input
|
||||
* (`LocalHarnessEntry.readyMark`, else the CLI registry's `composerReadyMark`): 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.
|
||||
*/
|
||||
readyMark?: string;
|
||||
/**
|
||||
* The last install of this agent through `POST /api/local-model/agent` failed, and it is
|
||||
* still not on this Mac. Kept in memory only: a restart of the server forgets it.
|
||||
*/
|
||||
error?: LocalModelAgentError;
|
||||
/** What the server accepts for this row right now. */
|
||||
actions: LocalModelHarnessAction[];
|
||||
}
|
||||
|
||||
/** Why an install of a coding agent from an installed model failed. */
|
||||
export interface LocalModelAgentError {
|
||||
/** `errHarnessFailed` with the agent's label. */
|
||||
message: LocalModelInstallMessage;
|
||||
/** The end of what the installer printed, as it is (English or the tool's own language). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a model of the library stands.
|
||||
* - `ready`: checked by Codeman, and the runtime (when it was asked) still lists it with the
|
||||
* digest that was stored.
|
||||
* - `changed`: checked, but the runtime now lists another digest under the name. It is never
|
||||
* started: `use` would fail with `model-changed`.
|
||||
* - `missing`: checked, but the runtime no longer lists it (something deleted it through the
|
||||
* runtime's port).
|
||||
* - `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 yet, or one another program
|
||||
* pulled through the runtime's port. Never startable.
|
||||
*/
|
||||
export type LocalModelLibraryStatus = 'ready' | 'changed' | 'missing' | 'unverified';
|
||||
|
||||
/** What can be done with one row of `library`: `POST /api/local-model/use` and `DELETE /api/local-model/models`. */
|
||||
export type LocalModelLibraryAction = 'use' | 'delete';
|
||||
|
||||
/** One model on this Mac. */
|
||||
export interface LocalModelLibraryRow {
|
||||
/** The model's name exactly as the runtime lists it (`name:tag`). */
|
||||
id: string;
|
||||
/** Its catalogue label, or `id` for a model the catalogue does not know. */
|
||||
label: string;
|
||||
/** The context it was checked with. Absent for an `unverified` row. */
|
||||
context?: number;
|
||||
/** Its size on disk: what the runtime reports, else what was recorded when it was checked. */
|
||||
sizeBytes?: number;
|
||||
/** `sizeBytes` as decimal GB text, no unit. Present exactly when `sizeBytes` is. */
|
||||
sizeGb?: string;
|
||||
/** It is the model in use: the one of an `installed` state. */
|
||||
active: boolean;
|
||||
/** Codeman checked it (`LocalModelStateFile.library` holds it). */
|
||||
verified: boolean;
|
||||
status: LocalModelLibraryStatus;
|
||||
/** What the server accepts for this row right now. `delete` needs the running runtime. */
|
||||
actions: LocalModelLibraryAction[];
|
||||
}
|
||||
|
||||
/** The disk use of the model store: the files directly in its blob folder, summed. */
|
||||
export interface LocalModelStoreView {
|
||||
bytes: number;
|
||||
/** `bytes` as decimal GB text, no unit. */
|
||||
gb: string;
|
||||
}
|
||||
|
||||
/** The `data` of `GET /api/local-model`. Admin only in multi-user mode. */
|
||||
export interface LocalModelStatus {
|
||||
hardware: HardwareFacts;
|
||||
@@ -511,6 +672,27 @@ export interface LocalModelRemoveRequest {
|
||||
removeModel?: boolean;
|
||||
}
|
||||
|
||||
/** Body of `POST /api/local-model/use`: a checked model of `library` to switch to. Nothing is downloaded. */
|
||||
export interface LocalModelUseRequest {
|
||||
/** `LocalModelLibraryRow.id` of a row whose `actions` list `use`. */
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/** Body of `DELETE /api/local-model/models`: one model to delete from the store. */
|
||||
export interface LocalModelDeleteRequest {
|
||||
/** `LocalModelLibraryRow.id` of a row whose `actions` list `delete`. Never a path. */
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of `POST /api/local-model/agent`: a coding agent of `harnesses` to install beside an
|
||||
* installed model. The click that sends it is the consent to the row's `installCommand`.
|
||||
*/
|
||||
export interface LocalModelAgentRequest {
|
||||
/** `LocalModelHarnessRow.cli` of a row whose `actions` list `install`. */
|
||||
cli: string;
|
||||
}
|
||||
|
||||
/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */
|
||||
export interface LocalModelRefusal {
|
||||
message: LocalModelInstallMessage;
|
||||
@@ -522,20 +704,26 @@ export type LocalModelVerbAnswer = LocalModelInstallView;
|
||||
/**
|
||||
* Why a verb was refused with 409.
|
||||
* - `job-running`: a job is running (a second install, a resume, a remove or a runtime action).
|
||||
* - `already-installed`: install while a model is installed. Remove it first.
|
||||
* - `already-installed`: install or use of the model that is already the one in use (at the
|
||||
* same context).
|
||||
* - `size-changed`: `acknowledgedBytes` is not the server's total; `expectedBytes` and
|
||||
* `expectedGb` carry that total, so the window can show it and ask again.
|
||||
* - `sessions-open`: remove, stop or a change of port while live sessions use the endpoint;
|
||||
* `sessions` names them. Their coding agents were given the runtime's address when they
|
||||
* started, so they would go on calling a port nothing of ours holds.
|
||||
* - `sessions-open`: remove, stop, a change of port, an install of another model or a switch
|
||||
* while live sessions use the endpoint; `sessions` names them. Their coding agents were
|
||||
* given the runtime's address and the model's name when they started, so they would go on
|
||||
* calling a port nothing of ours holds, or make the runtime swap models on every request.
|
||||
* - `wrong-state`: the verb makes no sense in the current state (see the route table above).
|
||||
* - `model-active`: a delete of the model the installation or the current job is about.
|
||||
* - `runtime-not-running`: a delete while the runtime is down: only the runtime deletes a model.
|
||||
*/
|
||||
export type LocalModelConflictReason =
|
||||
| 'job-running'
|
||||
| 'already-installed'
|
||||
| 'size-changed'
|
||||
| 'sessions-open'
|
||||
| 'wrong-state';
|
||||
| 'wrong-state'
|
||||
| 'model-active'
|
||||
| 'runtime-not-running';
|
||||
|
||||
/** The `data` of a 409 of an install verb. */
|
||||
export interface LocalModelConflict {
|
||||
@@ -622,6 +810,14 @@ export interface LocalModelStateFile {
|
||||
progress?: { completedBytes: number; totalBytes: number };
|
||||
/** What the runtime reported when the job checked the loaded model. */
|
||||
loaded?: { context: number; sizeBytes: number; sizeVramBytes: number };
|
||||
/**
|
||||
* Every model that PASSED the check through Codeman and is believed to be in the store, the
|
||||
* model in use among them. An entry is written when the `verify` step passes and dropped
|
||||
* when the model is deleted; `remove` drops the whole list. An `installed` file an earlier
|
||||
* build wrote has none: its one model then counts as the library (`libraryOf()` in
|
||||
* src/local-model/install-job.ts).
|
||||
*/
|
||||
library?: LocalModelLibraryEntry[];
|
||||
/**
|
||||
* Present once the `connect` step CLAIMED the connection, which it does before its two
|
||||
* writes: what uninstall must undo. A claim with nothing behind it (the step was interrupted
|
||||
@@ -636,3 +832,19 @@ export interface LocalModelStateFile {
|
||||
/** ISO time of the last write. */
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** One checked model of `LocalModelStateFile.library`. It holds no path. */
|
||||
export interface LocalModelLibraryEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
/** The context length in tokens it was checked with. */
|
||||
context: number;
|
||||
/** The digest `/api/tags` reported when it passed the check. A switch to it must find the same one. */
|
||||
digest: string;
|
||||
/** Its size on disk as the runtime reported it then, for a listing while the runtime is stopped. */
|
||||
bytes?: number;
|
||||
/** The coding agent of the job that checked it. A switch back takes it when it is installed. */
|
||||
cli?: { id: string; label: string };
|
||||
/** ISO time of the check. */
|
||||
verifiedAt?: string;
|
||||
}
|
||||
@@ -207,6 +207,15 @@ export interface LocalHarnessEntry {
|
||||
gated?: boolean;
|
||||
/** True: the first answer takes minutes, because the harness sends a very large fixed prompt. */
|
||||
slowFirstTurn?: boolean;
|
||||
/**
|
||||
* A literal with no white space that this harness's composer draws once it takes input, and
|
||||
* nothing before it does: what a script waits for before it sends the first prompt
|
||||
* (`GET /api/sessions/:id/wait-output`). Only a mark that was MEASURED on a session started
|
||||
* on a custom model goes here. Absent: the CLI registry's own
|
||||
* `capabilities.composerReadyMark` is used when it declares one, and a harness with neither
|
||||
* has no mark.
|
||||
*/
|
||||
readyMark?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,9 +257,18 @@ export interface HarnessFact {
|
||||
cli: string;
|
||||
/** The registry's display label ('OpenCode'). */
|
||||
label: string;
|
||||
/** The CLI's binary was found on this machine. */
|
||||
/**
|
||||
* The CLI can be started on this machine: the same answer the Run menu gets. For a launcher
|
||||
* that starts nothing by itself (`discovery.launcherProfile` in the CLI registry) that is its
|
||||
* binary AND something for it to launch.
|
||||
*/
|
||||
installed: boolean;
|
||||
/** Not installed, but the registry has an install command for this platform that needs no administrator rights. */
|
||||
/**
|
||||
* Not installed, but ONE command makes it startable: the registry has an install command for
|
||||
* this platform that needs no administrator rights, and the CLI is not a launcher. A launcher
|
||||
* needs a second install the command does not make (a profile, which is third-party code
|
||||
* with a consent of its own), so it is never installable from here.
|
||||
*/
|
||||
installable: boolean;
|
||||
/**
|
||||
* The literal install command the install job would run for this CLI: the STOCK
|
||||
@@ -259,6 +277,8 @@ export interface HarnessFact {
|
||||
* (`CandidateHarness.installCommand`).
|
||||
*/
|
||||
installCommand?: string;
|
||||
/** The registry's `capabilities.composerReadyMark`, when the CLI declares one and it holds no white space. */
|
||||
readyMark?: string;
|
||||
/** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */
|
||||
acceptsModelId: (modelId: string) => boolean;
|
||||
}
|
||||
@@ -554,6 +574,12 @@ export interface Candidate {
|
||||
installBytes: number;
|
||||
/** `installBytes` as decimal GB text, no unit. */
|
||||
installGb: string;
|
||||
/**
|
||||
* Set by the route, never by `recommend()`: this model at this context is a checked model of
|
||||
* the library on this Mac (`LocalModelInstallView.library`). `active`: it is the model in
|
||||
* use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded.
|
||||
*/
|
||||
library?: 'active' | 'ready';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -577,10 +603,10 @@ export interface Recommendation {
|
||||
/** Present exactly when `verdict` is `ok` or `limited`. */
|
||||
pick?: Candidate;
|
||||
/**
|
||||
* The next models that fit and have room on the disk (at most four), best first: tested
|
||||
* before not tested, then ok before unknown before slow speed, then the higher model rank.
|
||||
* Each is at the largest context it fits at, with the best harness that can run it there.
|
||||
* Empty when `verdict` is `none`.
|
||||
* Every other model that fits and has room on the disk (`MAX_ALTERNATIVES` is above what the
|
||||
* catalogue holds), best first: tested before not tested, then ok before unknown before
|
||||
* slow speed, then the higher model rank. Each is at the largest context it fits at, with
|
||||
* the best harness that can run it there. Empty when `verdict` is `none`.
|
||||
*/
|
||||
alternatives: Candidate[];
|
||||
/** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */
|
||||
|
||||
+50
-4
@@ -8,7 +8,7 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' (which CLI backend)
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' | 'jcode' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
@@ -19,6 +19,7 @@
|
||||
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
||||
* - CopilotConfig — GitHub Copilot CLI settings (model, allowAll, resume/continue)
|
||||
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
|
||||
* - JcodeConfig: jcode settings (model, resume)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
||||
@@ -60,7 +61,8 @@ export type SessionMode =
|
||||
| 'grok'
|
||||
| 'deepseek'
|
||||
| 'omp'
|
||||
| 'copilot';
|
||||
| 'copilot'
|
||||
| 'jcode';
|
||||
|
||||
/**
|
||||
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
|
||||
@@ -82,7 +84,18 @@ export interface SessionWriteOptions {
|
||||
|
||||
export type RemoteCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
|
||||
| 'shell'
|
||||
| 'claude'
|
||||
| 'opencode'
|
||||
| 'codex'
|
||||
| 'gemini'
|
||||
| 'antigravity'
|
||||
| 'pi'
|
||||
| 'grok'
|
||||
| 'deepseek'
|
||||
| 'omp'
|
||||
| 'copilot'
|
||||
| 'jcode'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -217,7 +230,18 @@ export interface RemoteSessionInfo {
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
|
||||
| 'shell'
|
||||
| 'claude'
|
||||
| 'opencode'
|
||||
| 'codex'
|
||||
| 'gemini'
|
||||
| 'antigravity'
|
||||
| 'pi'
|
||||
| 'grok'
|
||||
| 'deepseek'
|
||||
| 'omp'
|
||||
| 'copilot'
|
||||
| 'jcode'
|
||||
>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
@@ -631,6 +655,24 @@ export interface CopilotConfig {
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* jcode (1jehuang/jcode) session configuration.
|
||||
*
|
||||
* jcode has no approval prompts and no bypass flag, so there is nothing
|
||||
* privileged to configure; the provider and its login come from jcode's own
|
||||
* `~/.jcode/config.toml`. Verified against jcode 0.98.1.
|
||||
*/
|
||||
export interface JcodeConfig {
|
||||
/** Model ID passed via --model (e.g. "claude-opus-4-8", "gpt-5.5"). */
|
||||
model?: string;
|
||||
/**
|
||||
* Resume a jcode session (--resume): its id (`session_fox_<ms>_<hex>`) or its
|
||||
* short name (`fox`), as ONE plain word starting with a letter or digit, since
|
||||
* --resume's value is optional and a leading `-` would make it its own flag.
|
||||
*/
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek Harness (`dsh`) session configuration.
|
||||
*
|
||||
@@ -1017,6 +1059,8 @@ export interface SessionState {
|
||||
* `expiresAt` has passed reads as cold rather than as a stale warm.
|
||||
*/
|
||||
promptCache?: PromptCacheStatus;
|
||||
/** The model's context window in tokens from the last statusline report; absent until one arrives. */
|
||||
contextWindowSize?: number;
|
||||
/** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */
|
||||
statusLineExporter?: StatusLineExporter;
|
||||
/** Prompt-cache keep-warm opt-in; absent = follow the app-wide `promptCacheKeepWarm` default. */
|
||||
@@ -1106,6 +1150,8 @@ export interface SessionState {
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP-specific configuration (only for mode === 'omp') */
|
||||
ompConfig?: OmpConfig;
|
||||
/** jcode configuration (only for mode === 'jcode') */
|
||||
jcodeConfig?: JcodeConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
|
||||
+12
-1
@@ -68,7 +68,12 @@ export interface RawStatuslinePayload {
|
||||
five_hour?: { used_percentage?: number; resets_at?: number };
|
||||
seven_day?: { used_percentage?: number; resets_at?: number };
|
||||
};
|
||||
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
|
||||
context_window?: {
|
||||
used_percentage?: number;
|
||||
total_input_tokens?: number;
|
||||
total_output_tokens?: number;
|
||||
context_window_size?: number;
|
||||
};
|
||||
cost?: { total_cost_usd?: number };
|
||||
model?: { display_name?: string };
|
||||
/** Main-conversation prompt-cache statistics (Claude Code v2.1.251+); absent before the first API response. */
|
||||
@@ -259,6 +264,8 @@ export interface SessionStatus {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
contextUsedPercentage?: number;
|
||||
/** The model's context window in tokens (200k, or 1M with extended context). */
|
||||
contextWindowSize?: number;
|
||||
/** Prompt-cache state of the main conversation, when the statusline reports it. */
|
||||
cache?: PromptCacheStatus;
|
||||
}
|
||||
@@ -287,6 +294,10 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
|
||||
if (typeof cw?.used_percentage === 'number') {
|
||||
s.contextUsedPercentage = clampPct(cw.used_percentage);
|
||||
}
|
||||
const windowSize = cw?.context_window_size;
|
||||
if (typeof windowSize === 'number' && Number.isFinite(windowSize) && windowSize > 0) {
|
||||
s.contextWindowSize = windowSize;
|
||||
}
|
||||
const cache = parsePromptCache(data);
|
||||
if (cache) s.cache = cache;
|
||||
return Object.keys(s).length ? s : null;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* @fileoverview Token validation constants.
|
||||
*
|
||||
* Claude's context window is ~200k tokens, so 500k is a generous upper bound.
|
||||
* Claude's context window is 200k tokens, or 1M with extended context.
|
||||
*
|
||||
* @module utils/token-validation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maximum tokens allowed per session.
|
||||
* Claude's context is ~200k, so 500k is a safe upper bound for validation.
|
||||
* The largest context Claude reports today is 1M, the upper bound for validation.
|
||||
*/
|
||||
export const MAX_SESSION_TOKENS = 500_000;
|
||||
export const MAX_SESSION_TOKENS = 1_000_000;
|
||||
+3
-3
@@ -20,7 +20,7 @@
|
||||
|
||||
**OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`).
|
||||
|
||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivered two per keypress, the `execCommand('paste')` event and the keydown's default action, until the trap began cancelling the keydown, and the flag still guards a browser that sends both); the one-shot flag lives on the trap, never on a browser check. ⚠️ **Once `execCommand` has delivered, the trap cancels the keydown** (`target.keyEvent`, passed by both the main pane and a tile): Firefox's second paste is a separate task that races the trap's removal, and when it loses it lands on xterm's textarea and pastes again, which the flag cannot see. Never cancel the keydown unconditionally; Chromium and WebKit get their only paste from its default action. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||
|
||||
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. ⚠️ **Right-to-left text** (#715) has three parts on that one pipeline: `_renderMarkdown` pins `pre, code` to `dir="ltr"`, `_linkifyFilePaths` pins the anchors it builds the same way (`dir="auto"` skips a descendant that carries a `dir`, so `npm install` or a path at the head of a Persian sentence does not decide its direction; a markdown link's label is prose and stays unpinned), and `_applyBlockDirection()` gives each OUTERMOST prose block `dir="auto"`, keeping a `dir` the agent wrote and what it covers. ⚠️ That pass runs LAST, on the DOM, in both consumers (`_buildResponseViewerMessage`, `_renderFilePreviewText`), never in `_renderMarkdown` (its string must hold no `dir="auto"`) and never inside the linkifier (which returns early on a stale constants.js): an element already carrying `dir="auto"` re-resolves its direction on every insertion under it, so a code-only or path-only document went quadratic and a 200 KB list froze the tab for 40 s. ⚠️ The `.rv-text` block rules use logical properties (`padding-inline-start`, `border-inline-start`, `border-inline-end`, `text-align: start`, logical corner radii), never physical left and right, or an RTL block keeps its bullets and quote bar on the wrong side. The terminal pane is out of reach: xterm.js has no RTL support (xtermjs/xterm.js#701). Tests: `test/file-preview-markdown.test.ts`, `test/response-viewer-rtl.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
|
||||
|
||||
@@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
|
||||
|
||||
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "RecoLine truncated
|
||||
**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. The figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`; never after a switch, a job without a `model` step, which ends on the whole installed screen so the list of models is still there for the next Use or Delete), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE eventLine truncated
|
||||
|
||||
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
|
||||
|
||||
@@ -60,7 +60,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. The one opt-in exception is File Viewer (#759): `mobile.css` no longer hides it at phone width, so it stays off a phone only through the handheld default in `getDefaultSettings()` and the `html.mobile-init` startup rule (both pinned by that test), and shows once the setting is switched on on that phone. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
|
||||
|
||||
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @fileoverview Pure helpers for the opt-in shared per-case run options (`caseSettings` in
|
||||
* settings.json, written while `syncCaseState` is on). They run inside `mutateSettingsFile()`, so
|
||||
* every change is serialised with the file's other writers: two devices saving different cases
|
||||
* both land, where a replacement map would let the second discard the first.
|
||||
*/
|
||||
|
||||
export interface CaseRunOptions {
|
||||
agentTeams: boolean;
|
||||
opusContext1m: boolean;
|
||||
}
|
||||
|
||||
/** Most cases the shared map holds, counted after a merge (the schema bounds one request, not the file). */
|
||||
export const MAX_SHARED_CASES = 500;
|
||||
|
||||
export class SharedCaseLimitError extends Error {
|
||||
constructor() {
|
||||
super(`Shared case options are limited to ${MAX_SHARED_CASES} cases`);
|
||||
this.name = 'SharedCaseLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
function asMap(value: unknown): Record<string, CaseRunOptions> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, CaseRunOptions>) : {};
|
||||
}
|
||||
|
||||
/** The stored map with `incoming` merged in per case. Throws SharedCaseLimitError past the limit. */
|
||||
export function mergeCaseSettings(current: unknown, incoming: Record<string, CaseRunOptions>) {
|
||||
const merged = { ...asMap(current), ...incoming };
|
||||
if (Object.keys(merged).length > MAX_SHARED_CASES) throw new SharedCaseLimitError();
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** The map with `from` moved to `to`, or null when there is nothing to move. */
|
||||
export function renameCaseSettings(current: unknown, from: string, to: string) {
|
||||
const map = asMap(current);
|
||||
if (!Object.hasOwn(map, from)) return null;
|
||||
const { [from]: moved, ...rest } = map;
|
||||
return { ...rest, [to]: moved };
|
||||
}
|
||||
|
||||
/** The map without `name`, or null when it has no entry for it. */
|
||||
export function dropCaseSettings(current: unknown, name: string) {
|
||||
const map = asMap(current);
|
||||
if (!Object.hasOwn(map, name)) return null;
|
||||
const { [name]: _gone, ...rest } = map;
|
||||
return rest;
|
||||
}
|
||||
+64
-10
@@ -774,6 +774,7 @@ class CodemanApp {
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.activeSessionId = null;
|
||||
this._responseViewerMarkdown = '';
|
||||
|
||||
// ── Session detach / undock (beta) ───────────────────────────────────
|
||||
// A "solo window" is a popped-out browser window showing exactly one
|
||||
@@ -1232,6 +1233,11 @@ class CodemanApp {
|
||||
// and then applies the sidebar variant on top — do not call both.
|
||||
this.applySessionListLayout();
|
||||
this.applyTabOrientation();
|
||||
// The home screens' breakpoint listeners, once, before any home surface is
|
||||
// shown: a fold or unfold must swap the surface whichever width the page
|
||||
// loaded at (#702). Both self-gate on a home screen being up.
|
||||
this._wireMobileOverviewBreakpoint?.();
|
||||
this._wireHomeSessionsBreakpoint?.();
|
||||
this.initTabRailResize?.();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
@@ -2943,12 +2949,7 @@ class CodemanApp {
|
||||
ev.stopPropagation();
|
||||
const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code');
|
||||
const ok = code ? await this._copyText(code.textContent || '') : false;
|
||||
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
|
||||
copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed');
|
||||
clearTimeout(copyBtn._resetTimer);
|
||||
copyBtn._resetTimer = setTimeout(() => {
|
||||
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
|
||||
}, 1500);
|
||||
this._setCopyControlState(copyBtn, ok);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2992,6 +2993,50 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
_setCopyControlState(btn, success) {
|
||||
if (!btn) return;
|
||||
btn.classList.remove('rv-copied', 'rv-copy-failed');
|
||||
btn.classList.add(success ? 'rv-copied' : 'rv-copy-failed');
|
||||
clearTimeout(btn._resetTimer);
|
||||
btn._resetTimer = setTimeout(() => {
|
||||
btn.classList.remove('rv-copied', 'rv-copy-failed');
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw Markdown of the newest answer in a message list: every row of its
|
||||
* turn, or the last row when the reader emits no numeric turns (Codex, the
|
||||
* pane parser). Tool and status blocks share the assistant role but are not
|
||||
* the answer.
|
||||
*/
|
||||
_responseViewerMarkdownFrom(messages) {
|
||||
const answers = messages.filter(
|
||||
(msg) => msg && msg.role === 'assistant' && (!msg.kind || msg.kind === 'response') && msg.text?.trim()
|
||||
);
|
||||
const last = answers[answers.length - 1];
|
||||
if (!last) return '';
|
||||
if (typeof last.turn !== 'number') return last.text;
|
||||
return answers
|
||||
.filter((msg) => msg.turn === last.turn)
|
||||
.map((msg) => msg.text)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/** Copy the raw Markdown source of the answer the response viewer is showing. */
|
||||
async copyResponseViewerMarkdown() {
|
||||
const button = document.getElementById('responseViewerCopyMarkdown');
|
||||
const markdown = this._responseViewerMarkdown || '';
|
||||
if (!markdown) {
|
||||
this._setCopyControlState(button, false);
|
||||
this.showToast('No response content to copy', 'warning');
|
||||
return;
|
||||
}
|
||||
const copied = await this._copyText(markdown);
|
||||
this._setCopyControlState(button, copied);
|
||||
if (copied) this.showToast('Copied response as Markdown', 'success');
|
||||
else this.showToast('Failed to copy response as Markdown', 'error');
|
||||
}
|
||||
|
||||
/** Build one response-viewer message so the brief and full views share markup and CSS. */
|
||||
_buildResponseViewerMessage(text, role, agentLabel, meta) {
|
||||
const div = document.createElement('div');
|
||||
@@ -3181,9 +3226,11 @@ class CodemanApp {
|
||||
? 'OMP'
|
||||
: mode === 'copilot'
|
||||
? 'GitHub Copilot'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
: mode === 'jcode'
|
||||
? 'jcode'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
}
|
||||
|
||||
async toggleResponseViewer() {
|
||||
@@ -3234,6 +3281,8 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
this._responseViewerMarkdown = this._responseViewerMarkdownFrom(turnMessages) || lastResponse;
|
||||
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
if (turnMessages.length > 0) {
|
||||
// The whole last turn, rendered exactly as the full view renders that
|
||||
@@ -3313,6 +3362,9 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the brief view's answer when this history holds none of its own.
|
||||
this._responseViewerMarkdown = this._responseViewerMarkdownFrom(messages) || this._responseViewerMarkdown;
|
||||
|
||||
// Render conversation thread
|
||||
const agentLabel = this._getResponseViewerAgentLabel();
|
||||
body.innerHTML = '';
|
||||
@@ -11018,7 +11070,9 @@ class CodemanApp {
|
||||
? 'Kill Tmux & OMP'
|
||||
: session.mode === 'copilot'
|
||||
? 'Kill Tmux & GitHub Copilot'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
: session.mode === 'jcode'
|
||||
? 'Kill Tmux & jcode'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
}
|
||||
|
||||
document.getElementById('closeConfirmModal').classList.add('active');
|
||||
|
||||
@@ -126,20 +126,31 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._stopHomeSessionsClock();
|
||||
},
|
||||
|
||||
/** Re-render only when showing (called from the tab renderer's tail). */
|
||||
/**
|
||||
* Re-render only when showing (called from the tab renderer's tail). Asked
|
||||
* again when the debounce fires: `renderHomeSessions()` unhides the column,
|
||||
* so a render queued just before a fold to phone width hid it (#702) would
|
||||
* otherwise bring it back behind the phone overview.
|
||||
*/
|
||||
_refreshHomeSessionsIfVisible() {
|
||||
if (!this.isHomeSessionsVisible()) return;
|
||||
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
|
||||
this._debouncedCall(
|
||||
'homeSessions',
|
||||
() => {
|
||||
if (this.isHomeSessionsVisible() && this.shouldShowHomeSessions()) this.renderHomeSessions();
|
||||
},
|
||||
150
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated click listener for every row, plus a width listener so
|
||||
* resizing the window while on the home screen adds or drops the column
|
||||
* instead of leaving it overlapping the content it was sized to clear.
|
||||
* One delegated click listener for every row. The width listener lives in
|
||||
* `_wireHomeSessionsBreakpoint()`, installed at startup.
|
||||
*/
|
||||
_wireHomeSessions(el) {
|
||||
if (this._homeSessionsWired) return;
|
||||
this._homeSessionsWired = true;
|
||||
this._wireHomeSessionsBreakpoint();
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target?.closest?.('[data-hs-action]');
|
||||
@@ -150,20 +161,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
void this.openWebview?.(target.dataset.hsWebview);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
|
||||
const onChange = () => {
|
||||
// Only relevant while the welcome screen is up; entering a session
|
||||
// re-decides through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (!overlay || !overlay.classList.contains('visible')) return;
|
||||
this.showHomeSessions();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
/**
|
||||
* Add or drop the column when the window crosses its width gate, so it never
|
||||
* overlaps the content it was sized to clear. Installed ONCE at startup
|
||||
* (`init()` in app.js) like the phone overview's breakpoint listener, so a
|
||||
* page that loads on a phone and is unfolded to desktop width gets it too
|
||||
* (#702). Idempotent.
|
||||
*/
|
||||
_wireHomeSessionsBreakpoint() {
|
||||
if (this._homeSessionsBreakpointWired || !window.matchMedia) return;
|
||||
this._homeSessionsBreakpointWired = true;
|
||||
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
|
||||
const onChange = () => {
|
||||
// Only relevant while the welcome screen is up; entering a session
|
||||
// re-decides through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (!overlay || !overlay.classList.contains('visible')) return;
|
||||
this.showHomeSessions();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user