mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
848ab48b0a | ||
|
|
0b106b03eb | ||
|
|
54c591c84d | ||
|
|
2eece4f8f9 | ||
|
|
4d165d3fb1 | ||
|
|
d5ffc22f4a | ||
|
|
c2dfc775a3 | ||
|
|
e439cf0ef3 | ||
|
|
1f4c390e12 | ||
|
|
714050fe8a | ||
|
|
dfd3df8289 | ||
|
|
a0fbd1d28d | ||
|
|
272b56d47b | ||
|
|
1645ef5f5c | ||
|
|
627b76739c | ||
|
|
83e39c40a1 | ||
|
|
fec0409315 | ||
|
|
b4954c14cd | ||
|
|
c9f47b095a | ||
|
|
47ac16d6ab | ||
|
|
6d147c1bf1 | ||
|
|
92921b9107 | ||
|
|
614c7e6cd5 | ||
|
|
7659ca8b44 | ||
|
|
61037082d1 | ||
|
|
e71971cab4 | ||
|
|
e60b5a8a2c | ||
|
|
1d85909a06 | ||
|
|
1da2fa2529 | ||
|
|
45ea2e1d32 | ||
|
|
f6aa50239f | ||
|
|
9676e90133 | ||
|
|
bf73a84732 | ||
|
|
8d358aaa26 | ||
|
|
a9b48320a3 | ||
|
|
95a3b87062 | ||
|
|
8cef31086b | ||
|
|
55790964b7 | ||
|
|
5ae574374f | ||
|
|
47f209bf0a | ||
|
|
d81a4a76de | ||
|
|
8841bcc93f | ||
|
|
77ba41f8da | ||
|
|
b80d47aff8 | ||
|
|
d67da5c9d0 | ||
|
|
d6c3386102 | ||
|
|
10c263a5b8 | ||
|
|
b070c9ee65 | ||
|
|
e98127a804 | ||
|
|
3e3a4612e6 | ||
|
|
a5283c565d | ||
|
|
b46588f247 | ||
|
|
e6ddb0485a | ||
|
|
334884e96a | ||
|
|
69a71287e6 | ||
|
|
7485afecaf | ||
|
|
0a52a99ca9 | ||
|
|
c46e87fd7a | ||
|
|
dd230b0b6e |
@@ -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 only inside a Codeman-managed session.",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.2",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -28,12 +28,15 @@ The frontend is plain JS served from `src/web/public/` with no bundler in dev: e
|
||||
CI runs all of these, so save yourself a round trip:
|
||||
|
||||
```bash
|
||||
npm run typecheck # tsc --noEmit, strict mode
|
||||
npm run typecheck # tsc --noEmit, strict mode
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||
npm run check:browser-excludes # every browser-driven test is kept out of `npm test`
|
||||
```
|
||||
|
||||
`npm install` also installs a `pre-push` git hook that runs these static checks (about 10-40s, machine-dependent) and blocks the push if one fails. It skips itself when you push something other than the checked-out HEAD, or when the tree has uncommitted changes the checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own.
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -34,6 +34,13 @@ jobs:
|
||||
- name: Frontend JS syntax check
|
||||
run: npm run check:frontend-syntax
|
||||
|
||||
# Asks `vitest list` what CI would actually collect, rather than matching
|
||||
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
|
||||
# (config/test-suites.ts) passes locally and dies in the test job with
|
||||
# "browserType.launch: Executable doesn't exist".
|
||||
- name: Browser-test exclusion check
|
||||
run: npm run check:browser-excludes
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.33.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e439cf0: ### Thanks
|
||||
- @aakhter for keeping web-tab events private to their owner in multi-user mode (#501), with end-to-end isolation tests that fail without the fix, and for the browser-test exclusion check and pre-push hook (#500), including the hooks-dir resolution that never writes outside the repo's own `.git/hooks`.
|
||||
- @opticon454 for keeping CLIs installed from Settings across Docker container updates (#490) and for the static Git identity for the Docker images (#492).
|
||||
- @timkjr for letting a Shell pane's scroll-to-top reach tmux history (#494), with tests that fail on the commit before each fix.
|
||||
- @JDProfresh for tracking down why wheel and touch scrolling did nothing in Claude's default inline view (#498), with the tmux measurements that proved it.
|
||||
- @irisitymichaelgrundberg for the follow-up that makes an agent waiting on artifact comments raise its alert again (#491).
|
||||
|
||||
**A tab stays busy while Claude waits for its own workers.** When Claude hands work to an ultracode workflow or background agents, it ends its turn with `✻ Waiting for 1 dynamic workflow to finish` and resumes by itself when they report back. The idle probe used to call that session idle for the whole wait, and at phone width nothing on screen changes for minutes. A new optional registry field, `capabilities.workDetect.awaitingLine`, names that closing row, and only the newest column-0 row directly above the composer counts, so the session goes idle normally once the follow-up turn ends.
|
||||
|
||||
**Prompts sent through the API are no longer left unsent.** A prompt posted to `POST /api/sessions/:id/input` without `useMux` was written into the pane in one piece, and Claude Code (measured on 2.1.283) takes a burst of about a hundred characters or more as a paste, so the trailing `\r` became a newline and the prompt sat on the composer while the route answered 200. Short prompts went through, which is why it looked random; Codex and OpenCode showed the same thing. A plain prompt (printable text plus exactly one trailing `\r`) now goes through tmux: the text is typed, Enter is pressed as its own key, and the server presses it again while the prompt is still on the composer. Raw frames (escape sequences, a bracketed paste, a line feed, a bare `\r`) and an explicit `"useMux": false` keep the direct write. The same fix reaches cron jobs in "Paste (direct)" input mode, which reported `prompt_sent` for a prompt that never left the composer: the text is written raw, Enter follows as its own write 300 ms later, and the session presses it again while the prompt is still unsent. A cron run with no session to write to now fails instead of reporting the prompt as sent.
|
||||
|
||||
**Scrolling works again in Claude's default inline view (#498).** Wheel and touch gestures were forwarded to every Claude 2.1.187+ session as mouse reports, but only Claude's fullscreen renderer (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`) listens for them, so in the default view scrolling did nothing. Codeman now forwards them only while Claude has mouse tracking switched on, and otherwise scrolls the terminal's own scrollback.
|
||||
|
||||
**Shell panes scroll back into tmux history (#494).** Scrolling to the top of a Shell pane now pulls the most recent 1 MiB of its tmux history, so output that arrived in a burst is reachable without pressing **Load full history**, which still loads the rest.
|
||||
|
||||
**An agent waiting on artifact comments alerts again (#491).** A session whose agent published an artifact and is waiting for somebody to comment on it now raises the normal idle alert and lands in NEEDS YOU, instead of being treated as busy with background work.
|
||||
|
||||
**Web-tab changes stay private in multi-user mode (#501).** The `webview:changed` event reached every connected user, exposing the ids of other users' web-tab creates, edits and deletes. It now carries the tab's owner and reaches that owner plus admins only. Single-user mode is unchanged apart from a new optional `owner` field on the event.
|
||||
|
||||
**Phone header tabs look like tabs (#504).** On phones every header tab is now a chip with a fill and a border, the Alt+N digit (a keyboard hint a phone cannot use) is hidden, names get 80px instead of 50px, and the strip fades at whichever edge still has tabs scrolled out of view.
|
||||
|
||||
**Docker: CLIs installed from Settings survive container updates (#490).** On the Compose deployment, CLIs installed from App Settings (DeepSeek, Pi and other npm-based CLIs) now go to `~/.local` on the persistent home mount, and `~/.local/bin` is on the image PATH, so recreating the container no longer discards them. Anything installed from Settings before this release has to be installed once more after the rebuild.
|
||||
|
||||
**Docker: a static Git identity for the server and agent images (#492).** Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` (or `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` / `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` on a bare-host install) and the identity is written to `/etc/gitconfig` in the server image and the Docker-case agent image. A half-set pair is refused on every build path. Both Docker changes edit `server.Dockerfile`, so the in-app updater asks Compose deployments to rebuild with `docker/Start-Codeman.sh` instead of updating in place.
|
||||
|
||||
**Contributor tooling (#500).** `npm run check:browser-excludes`, now a CI step, fails when a test that drives a real browser is still collected by `npm test`. `npm install` also installs a pre-push hook that runs the static CI checks before a push; it steps aside when the pushed ref is not HEAD or the tree has uncommitted changes the checks would read, and `CODEMAN_SKIP_PREPUSH=1 git push` skips it once.
|
||||
|
||||
**Fixes applied while landing.** A Shell pane's scroll-to-top (#494) no longer re-pulls the same window on every gesture once the browser's 50,000-row scrollback is full, and a pull that hit the byte cap no longer claims the older history is gone. The scroll-routing diagnostics (#498) now log whether Claude has mouse tracking on. The artifact-comment check (#491) also refuses a footer cut off in the middle of the chip. The pre-push hook (#500) steps aside when `npm` is not on PATH, as in some GUI git clients, instead of blocking every push. The Docker Git identity error (#492) names the two variables to set. New tests pin the image PATH order, the identity on both agent-image build paths, and the `?full=1&tail=` terminal route.
|
||||
|
||||
## 1.33.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ### Thanks
|
||||
- @irisitymichaelgrundberg for closing sessions whose agent exited cleanly (#486), built carefully around every way a pane exit can lie (a SIGKILL with no status, a single misread), with the `.claude-images` guard split into its own commit as asked.
|
||||
- @opticon454 for the live-refreshing case picker and Manage search (#483), and for the uv/uvx, libsecret and pnpm additions to the Docker images (#487, #485).
|
||||
|
||||
**Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
|
||||
|
||||
**Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show.
|
||||
|
||||
**An oversized paste no longer jams a session's input (#484).** A single input over the 64 KiB frame limit used to be refused by both transports, retried every 2 s forever, block every later input for that session and come back from localStorage on each reload. Pastes over the limit are now split into in-limit frames delivered in order (up to 1 MiB; larger ones are refused with a toast and never queued), a refused frame is dropped instead of retried, frames persisted by an older build are pruned on load, and the WebSocket answers an oversized frame with an explicit `too_large` error instead of silence.
|
||||
|
||||
- 8841bcc: Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case.
|
||||
- 8841bcc: The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one.
|
||||
|
||||
Thanks @opticon454.
|
||||
|
||||
- 77ba41f: Install `uv` and `uvx` in the Compose server image and the agent image, so MCP servers launched with `uvx` (such as the Nginx Proxy Manager MCP) can be enabled by Codex instead of failing with `uvx` not found. Both images also install `libsecret-1-0`, the native library the `keytar` dependency of the Azure DevOps MCP (`@azure-devops/mcp`) needs; without it the server crashes before answering the MCP initialize handshake.
|
||||
|
||||
The Compose server image now also carries `pnpm`: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed with `dsh: pnpm not found on PATH` there. Because this release changes `server.Dockerfile`, the in-app updater asks Compose deployments to rebuild the image (`Update-Codeman.sh`) rather than applying it in place.
|
||||
|
||||
Thanks @opticon454 (#487, #485).
|
||||
|
||||
## 1.33.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- CLI management from Settings (#476, finishing the CLI registry work from #343). `~/.codeman/clis.json` used to be hand-edit only; with the new opt-in `cliManagementEnabled` switch (synced, default OFF) App Settings → Agents & CLIs can enable or disable any CLI, install a missing stock CLI with its vetted install command, and add, edit or remove custom CLIs. Six new endpoints back it (`GET`/`POST /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id`), documented in `docs/api-reference.md`. Every write is refused while the switch is off, is admin-only in multi-user mode, is serialized on one queue, and refuses to overwrite a `clis.json` that does not parse or has group/world permission bits. A custom entry is re-validated through the same schema as the stock ones and its install text is never executed. `shell` cannot be disabled. The Run menu and the welcome screen are now built from the enabled catalogue, so the welcome screen also offers Codex, Shell and any custom CLI, and the stock Claude entry is labelled "Claude Code".
|
||||
|
||||
Models: Opus 5.5 (`claude-opus-5-5`, 1M context capable) is offered in App Settings → Models and in task routing (#480).
|
||||
|
||||
Self-update: on a macOS `launchd-daemon` install, a Homebrew node upgrade could leave `update-status.json` stuck at `queued`, which made every later update fail with "An update is already in progress." The updater now falls back to `node` on PATH when the server's own node binary is gone, and an in-flight status that has not been written for 15 minutes is failed on the next read. A graceful shutdown that hangs is now force-exited after 10 s (and the launchd updater SIGKILLs a server that has not exited after 30 s), so launchd can start the new build instead of leaving the service down (#478). Both fixes protect updates that start FROM this release.
|
||||
|
||||
Session Manager (Cmd+K): rows keep their `mode`, `claudeSessionId` and `resumeId`, so the ⋯ menu's Resume session relaunches a Codex row as Codex on its own conversation, and the mode badge shows as it does on the home list (#477).
|
||||
|
||||
Maintainer fixes applied while landing #457: renaming a tab to the name it already has (the Session Options field saves on blur) is now a no-op, so it no longer pins the placeholder as the `/resume` title again; Docker sessions skip the transcript title sync, since their transcript lives in the container; and the agent skill's messaging examples no longer use a `w<N>-` name as the peer name.
|
||||
|
||||
Tests: the suite strips every inherited `CODEMAN_*` variable, so running it inside a Docker Compose deployment no longer writes into the deployment's real case root (#479).
|
||||
|
||||
### Thanks
|
||||
- @opticon454 for CLI management (#476), the last piece of the CLI registry, with every review item answered in one round, and for splitting the test isolation fix out into #479.
|
||||
- @shenlvkang-collab for the `/resume` title fix (#457) and the careful diagnosis behind it.
|
||||
- @julian3xl for the Session Manager row fix (#477), their first contribution.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 69a7128: fix(sessions): stop pinning the `w1-myapp` placeholder as Claude's session title. Local Claude spawns passed the tab name as `--name`, which is also the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own, so every conversation of a case showed up in `/resume` as the same `w1-myapp` and none got a generated title. Only a name the user chose is pinned now; placeholder and auto-named tabs let Claude title the conversation again. Renaming a Claude tab also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (a tab that was spawned with `--name` keeps re-appending its own title until its next respawn, so the rename wins from then on). Orchestrators that rely on a fixed peer name should give workers a descriptive `sessionName` rather than a `w<N>-` one.
|
||||
|
||||
## 1.32.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -34,6 +34,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- To land a commit on master **without** switching branches (which would yank the tree out from under the other session): `git push origin HEAD:master` then `git branch -f master HEAD`. Never `git checkout master` to "fix" it.
|
||||
- **Never `git add -A`/`git add .`** — stage explicit paths. A sweep will pick up another session's WIP.
|
||||
- Another session's broken WIP can block `npm run build`, since `tsc` is the first step and the build gates on it. That is not your bug to fix. ⚠️ `tsc` still EMITS on type errors, so a failed `npm run build` leaves a rebuilt `dist/index.js` compiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blocked `tsc`, run the asset stage of `scripts/build.mjs` (everything after the `tsc`/`chmod` lines is independent of it).
|
||||
- **A pre-push failure in a file you did not touch is another session's WIP.** Push with `CODEMAN_SKIP_PREPUSH=1 git push` and leave it alone. (The hook already skips itself when the tree has uncommitted changes in a path it checks, so this mostly happens once the other session has committed.)
|
||||
|
||||
## CRITICAL: Always Test Before Deploying
|
||||
|
||||
@@ -77,7 +78,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.32.1 (must match `package.json`)
|
||||
**Version**: 1.33.2 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -112,6 +113,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) |
|
||||
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
|
||||
| Frontend JS syntax check | `npm run check:frontend-syntax` (`scripts/check-frontend-syntax.mjs`; runs in CI) |
|
||||
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
|
||||
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
|
||||
| Excluded-suite runners | `npm run test:browser` · `npm run test:mobile` · `npm run test:perf` · `npm run test:all` (everything, environmental failures included) — see Testing |
|
||||
| Production start | `npm run start` |
|
||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||
@@ -120,7 +123,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
|
||||
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 9 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
|
||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 14 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
|
||||
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||
|
||||
@@ -128,7 +131,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write
|
||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||
@@ -203,9 +206,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer all through a turn, and its working line (`✻ Actualizing… (13m 23s · …)`) is invisible to `SPINNER_PATTERN`, keyword lists and the raw stream. `_confirmIdle()` (session.ts) requires the pane to go quiet AND the SCREEN (`capturePaneText()` + the working-line pattern) to agree; a sustained run of repaints (`session-activity.ts`) marks a turn as started. ⚠️ The composer glyph and working line are per-CLI registry DATA (`capabilities.workDetect`), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ `workingLine` is config-supplied and runs on the PTY hot path, so it must compile through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()` (ReDoS guard; null, not throw). → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
|
||||
|
||||
⚠️ **A turn that ENDED waiting for its own workers is working, not idle.** Claude closes such a turn with `✻ Waiting for 1 dynamic workflow to finish` (background agents / ultracode) and resumes by itself; `capabilities.workDetect.awaitingLine` makes the idle probe count it as work. ⚠️ Claude never redraws that row, so it stays on screen after the workers finish: test it ONLY as the newest column-0 row above the composer (`isAwaitingWorkers()`), never pane-wide and never on the stream. → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
|
||||
|
||||
⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`.
|
||||
|
||||
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
|
||||
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via `cleanupSession()` (`pane-exit-sweep.ts`): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (`paneLifecycleInFlight`) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
|
||||
|
||||
**Dead-pane respawn resume pin** (`_buildRespawnPaneOptionsWithResumePin()`, session.ts): recovering a dead pane, like a custom-model `restartCli()`, must pin the conversation or claude refuses the reused `--session-id`. The pin takes the first transcript-backed candidate (chain tail, launch seed, own id), never `_claudeSessionId`, adds nothing when none is backed, and is never applied to remote or docker sessions. → [architecture-invariants#dead-pane-respawn-the-resume-pin](docs/architecture-invariants.md#dead-pane-respawn-the-resume-pin)
|
||||
|
||||
@@ -227,9 +232,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md`
|
||||
|
||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
|
||||
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`) 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 (read-only). → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`) 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`, 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)**: `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 eight **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. → [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)
|
||||
|
||||
@@ -243,7 +248,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
|
||||
**Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
|
||||
|
||||
@@ -265,7 +270,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and Shell loads the rest only via **Load full history**, never on ordinary scroll. ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||
|
||||
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
|
||||
|
||||
@@ -275,7 +280,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**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, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **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)
|
||||
|
||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY**; ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||
|
||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> • <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
⭐ <strong>喜欢 Codeman?<a href="https://github.com/Ark0N/Codeman">在 GitHub 上给它点个 Star 吧!</a></strong>只需轻点一下,就能帮助更多人发现这个项目。⭐
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"id": "claude",
|
||||
"label": "Claude",
|
||||
"label": "Claude Code",
|
||||
"shortBadge": "CC",
|
||||
"enabled": true,
|
||||
"order": 0,
|
||||
|
||||
@@ -15,6 +15,12 @@ TZ=Australia/Perth
|
||||
# this value rebuilds the image with a matching account.
|
||||
CODEMAN_RUNTIME_USER=codeman
|
||||
|
||||
# Optional Git identity for commits made by Codeman and Docker-case agents. These values
|
||||
# are written to each image's system Git configuration when it is rebuilt, so
|
||||
# deployments can configure a consistent default. Set both values together.
|
||||
# GIT_USER_NAME=
|
||||
# GIT_USER_EMAIL=
|
||||
|
||||
# Required. Persistent Codeman application data, CLI credentials, and session
|
||||
# state are stored here on the host and mounted at the runtime account's home
|
||||
# directory in the container.
|
||||
|
||||
@@ -67,6 +67,27 @@ two volumes are removed, by name within this Compose project; any volume a
|
||||
`docker-compose.override.yml` adds is left alone, and application data and
|
||||
case workspaces are host bind mounts, never touched either way.
|
||||
|
||||
## Git commit identity
|
||||
|
||||
Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` before rebuilding:
|
||||
|
||||
```sh
|
||||
GIT_USER_NAME='Your Name'
|
||||
GIT_USER_EMAIL='you@example.com'
|
||||
```
|
||||
|
||||
Compose passes the values to the Codeman server build, and to the server process
|
||||
when it builds Docker-case agent images. Both images write the pair to Git's
|
||||
system configuration during their build, so commits retain the same identity
|
||||
after a container or agent image is recreated. Set both values together; an
|
||||
image build with only one value fails rather than using a partial identity. An
|
||||
identity already present in `CODEMAN_APPDATA_PATH`'s `~/.gitconfig` overrides
|
||||
the server image's system-level default.
|
||||
|
||||
Run `bash docker/Start-Codeman.sh` after changing the server values. Rebuild an
|
||||
existing agent image with `node scripts/build-agent-image.mjs --no-cache` in the
|
||||
server container, then recreate any Docker cases that should use it.
|
||||
|
||||
## Private repositories (GitHub and Azure DevOps)
|
||||
|
||||
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
|
||||
|
||||
@@ -17,6 +17,7 @@ FROM node:22-bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
libsecret-1-0 \
|
||||
tmux \
|
||||
ripgrep \
|
||||
curl \
|
||||
@@ -126,6 +127,10 @@ RUN set -eux; \
|
||||
# A different order is a different RUN string, which is a different layer hash and
|
||||
# so a needless cache miss between a bare `docker build` and a scripted one.
|
||||
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
|
||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
||||
RUN npm install -g ${CLI_NPM_PACKAGES} \
|
||||
&& npm cache clean --force
|
||||
|
||||
@@ -249,6 +254,21 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
||||
&& chgrp -R 0 /home/agent \
|
||||
&& chmod -R g=u /home/agent
|
||||
|
||||
# Docker cases have a fresh, container-owned home directory. Declare the
|
||||
# optional identity here so changing it invalidates only this final layer, then
|
||||
# configure Git's system defaults. A user-level config still takes precedence.
|
||||
ARG GIT_USER_EMAIL=
|
||||
ARG GIT_USER_NAME=
|
||||
RUN set -eux; \
|
||||
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
|
||||
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
|
||||
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
git config --system user.name "${GIT_USER_NAME}"; \
|
||||
git config --system user.email "${GIT_USER_EMAIL}"; \
|
||||
fi
|
||||
|
||||
USER agent
|
||||
WORKDIR /home/agent
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ services:
|
||||
dockerfile: docker/server.Dockerfile
|
||||
args:
|
||||
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
|
||||
GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
||||
GIT_USER_NAME: ${GIT_USER_NAME:-}
|
||||
PGID: ${PGID:-1000}
|
||||
PUID: ${PUID:-1000}
|
||||
image: ${CODEMAN_IMAGE}
|
||||
@@ -32,6 +34,10 @@ services:
|
||||
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
|
||||
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
|
||||
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
|
||||
# Passed through only so Codeman can use the same identity when it builds
|
||||
# the Docker-case agent image.
|
||||
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
|
||||
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: ${GIT_USER_NAME:-}
|
||||
# Extra Host-header allowlist entries for a reverse-proxied deployment
|
||||
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
|
||||
# defaults to empty rather than requiring a line in every .env.
|
||||
|
||||
@@ -39,6 +39,7 @@ RUN apt-get update \
|
||||
curl \
|
||||
g++ \
|
||||
git \
|
||||
libsecret-1-0 \
|
||||
make \
|
||||
openssh-client \
|
||||
procps \
|
||||
@@ -212,13 +213,28 @@ RUN set -eux; \
|
||||
# minimal image of this exact shape). The four CLIs live only in this prefix,
|
||||
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
|
||||
# system directories for the root part of the start.
|
||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
||||
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
|
||||
ENV PATH=$PATH:/opt/codeman-cli/bin
|
||||
# CLIs installed at runtime (Settings -> CLIs, npm redirected to ~/.local by installEnv()) live on the
|
||||
# persistent home mount, so they survive a container recreate. Appended for the same reason as above.
|
||||
ENV PATH=$PATH:/home/${CODEMAN_RUNTIME_USER}/.local/bin
|
||||
# pnpm is not an agent CLI: it is here because `dsh plugin` (DeepSeek Harness, which
|
||||
# this image leaves to be installed at runtime, see SERVER_INTENTIONAL_OMISSIONS in
|
||||
# test/docker-agent-image-coverage.test.ts) spawns a literal `pnpm` with no npm
|
||||
# fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed
|
||||
# with `dsh: pnpm not found on PATH` (exit 127) on this image. The agent image
|
||||
# already carries it for the same reason (#352). It lives in the same
|
||||
# runtime-writable prefix as the CLIs, so a session can update it in place.
|
||||
RUN npm install --global \
|
||||
@anthropic-ai/claude-code@2.1.258 \
|
||||
@google/gemini-cli@0.58.0 \
|
||||
@openai/codex@0.152.1 \
|
||||
opencode-ai@1.18.26 \
|
||||
pnpm@12.6.0 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Keep the web server and every local Codeman session unprivileged. PUID and
|
||||
@@ -286,6 +302,21 @@ EXPOSE 3000
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod 0755 /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Declare the optional identity immediately before configuring it so a change
|
||||
# invalidates only this final layer. This is declarative setup: a persisted
|
||||
# ~/.gitconfig in CODEMAN_APPDATA_PATH still overrides the system-level values.
|
||||
ARG GIT_USER_EMAIL=
|
||||
ARG GIT_USER_NAME=
|
||||
RUN set -eux; \
|
||||
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
|
||||
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
|
||||
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
git config --system user.name "${GIT_USER_NAME}"; \
|
||||
git config --system user.email "${GIT_USER_EMAIL}"; \
|
||||
fi
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
CMD ["node", "dist/index.js", "web"]
|
||||
|
||||
@@ -757,3 +757,13 @@ works, and its replies arrive tagged `from-name="w9-msgtest"` (a derived-name
|
||||
worker's replies carry no `from-name`). A quick-start without `sessionName` has an
|
||||
empty Codeman name, so the peer name stays derived: agents should name their
|
||||
workers. Tests: `test/name-flag-injection.test.ts`.
|
||||
|
||||
Later narrowing: `--name` is not only the peer name but also the `/resume` picker
|
||||
entry and the terminal title, and a pinned title stops Claude generating its own, so
|
||||
pinning the `w1-myapp` placeholder listed every conversation of a case under the same
|
||||
name in `/resume`. Only a manual name is pinned now (`Session.cliPinnedName`,
|
||||
`nameSource === 'manual'`, carried to the builders as `cliName`); placeholder and auto
|
||||
names leave Claude to title the conversation. A rename in Codeman appends a
|
||||
`custom-title` row to the conversation's transcript (`claude-session-title.ts`), the
|
||||
row `/rename` writes. Tests: `test/claude-resume-title.test.ts`,
|
||||
`test/routes/session-name-routes.test.ts`.
|
||||
|
||||
@@ -311,6 +311,15 @@ worker's prompt but never submitted, and the wait then runs its full timeout on
|
||||
turn that never started. Verified live; this is the most common silent failure on
|
||||
this endpoint.
|
||||
|
||||
A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is
|
||||
delivered through tmux even without `useMux`: the text is typed, Enter is pressed as
|
||||
a separate key, and the server re-presses Enter while the prompt is still visibly
|
||||
sitting on the composer. Written straight into the pane in one piece, a prompt of
|
||||
about a hundred characters or more is taken as a paste by Claude Code, its `\r`
|
||||
becomes a newline, and the prompt stays unsent (measured on 2.1.283). Any other
|
||||
input (escape sequences, a bracketed-paste frame, a line feed, a bare `\r`) keeps
|
||||
the raw write, and an explicit `"useMux": false` forces it.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
@@ -691,6 +700,19 @@ normal `caseName`/`mode`/etc. body)
|
||||
jarring than a full relaunch, and folding it into the one-shot path is
|
||||
separate work — see `docs/custom-model-endpoints-plan.md`).
|
||||
|
||||
## CLI management
|
||||
|
||||
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| -------- | ----------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/clis` | none | Every entry, disabled ones included: `id`, `label`, `shortBadge`, `order`, `kind`, `enabled`, `stock`, `installed`, and `installCommand` for a stock entry. Not gated; a non-admin in multi-user mode gets `[]`. |
|
||||
| `PUT` | `/api/clis/:id` | `{ enabled }` | Toggle an existing entry, stock or custom. `404` for an unknown id; `400 INVALID_INPUT` when disabling a `kind: 'shell'` entry. |
|
||||
| `POST` | `/api/clis/:id/install` | none | Run a **stock** entry's install command (never a custom one: `400`). `409 CONFLICT` while an install for the same id is running; `422 OPERATION_FAILED` with the output tail when it fails. Never enables the entry. |
|
||||
| `POST` | `/api/clis` | `{ id, label, shortBadge, binaries, argv, enabled? }` | Create a custom entry. `409 ALREADY_EXISTS` for a stock id or an existing custom id. `enabled` defaults to `true`. |
|
||||
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
|
||||
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,314 @@
|
||||
# CLI management Settings UI + write API — plan
|
||||
|
||||
> Tracked separately from `DEPLOYMENT_PLAN.md` (PR B2, merged) and `docs/copilot-integration-plan.md`
|
||||
> (parked). This is "PR C" from the original #343 review: *"settings UI + write endpoints +
|
||||
> auto-install, once we've settled the trust model... I want to make that call on its own, not
|
||||
> inside a 100-file diff."*
|
||||
>
|
||||
> **Phase 0 is CLOSED as of 2026-09-21** — all three original pieces are IN SCOPE (expanded from
|
||||
> this plan's first draft, which recommended #2/#3 as separate/out-of-scope; the user chose full
|
||||
> scope instead, with the risk called out explicitly for #3 before confirming). See "Decisions"
|
||||
> below for the full record.
|
||||
|
||||
## Status as of 2026-09-22
|
||||
|
||||
**Phases 1–6 are ALL IMPLEMENTED** (commits `da07b38c` "add cliManagementEnabled flag and GET
|
||||
/api/clis" and `db4557d9` "Phases 3-6 - write API + custom entries + Settings UI", both on this
|
||||
branch, `feat/cli-management`). Confirmed present in the tree: `cliManagementEnabled` in
|
||||
`SettingsUpdateSchema`; `GET /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`,
|
||||
`POST /api/clis`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id` in
|
||||
`src/web/routes/cli-registry-routes.ts`; the `shell`/`claude` `UNDISABLEABLE_IDS` backend guard;
|
||||
`isAdmin(req)` gating on both the list and write routes; `appendAdminAudit` wired into the install
|
||||
route; tmp+rename+`0o600` writes in `registry-writer.ts`; the full Settings UI (row list, toggle,
|
||||
Install button, custom-entry create/edit/delete form) in `settings-ui.js` + `index.html`.
|
||||
`test/routes/cli-registry-routes.test.ts` (425 lines) and `test/cli-registry-no-id-branching.test.ts`
|
||||
cover it. This status section, plus the fix and gap below, is the one piece of that work done in
|
||||
a *different* session from the one that wrote Phases 1–6 — reviewed by reading the diff and
|
||||
verifying each claim against the actual routes/tests, not by re-implementing anything.
|
||||
|
||||
### Gotcha found and fixed (commit `0c77dd0a`)
|
||||
|
||||
**Toggling a CLI off in Settings had no effect anywhere except the Settings row itself.**
|
||||
`window.__codemanCliAvailable` — the flag `isCliAvailable()` reads client-side to gate the
|
||||
welcome-screen buttons, the Run-menu dropdown and the mobile overview — is injected **once**, at
|
||||
initial page render (`server.ts`), built purely from each CLI's own installed-on-PATH resolver
|
||||
(`isClaudeAvailable()` etc.), with **no reference to the registry's `enabled` flag at all**. So
|
||||
disabling a CLI here updated its own row and nothing else — every launch surface kept offering it,
|
||||
both live and after a full page reload, since even a *fresh* render never consulted the registry.
|
||||
Root-caused and reported by the user testing the live feature ("toggle those off, they still
|
||||
appear in that menu and on the front main screen").
|
||||
|
||||
Fixed two places:
|
||||
- `server.ts`: after building `available`, intersect the nine real `SessionMode` ids against
|
||||
`enabledClis()`. `git`/`cloudflared` (utility binaries, not CLI registry entries) and
|
||||
`deepseekBinary` (a secondary installed-only flag for the "add a profile" affordance) are
|
||||
deliberately left alone — they were never registry-gated to begin with.
|
||||
- `settings-ui.js`: `toggleCliEnabled()` now patches `window.__codemanCliAvailable` in place and
|
||||
refreshes the welcome screen, the mobile overview and an already-open Run menu, mirroring the
|
||||
existing `installDeepSeekProfile()` pattern for the same "injected once, needs an explicit
|
||||
patch" reason — the server-side fix alone still left every surface stale until the next reload.
|
||||
|
||||
New test in `test/render-index-html.test.ts`: an installed-but-disabled CLI (codex, forced via
|
||||
`clis.json` + `reloadCliRegistry()`) reads as unavailable, while an installed-and-enabled one
|
||||
(claude) is unaffected by the override.
|
||||
|
||||
**Verified on the Debian devbox** (`codeman-devbox`, real tmux — this sandbox has none and
|
||||
`WebServer`'s constructor hard-requires it): typecheck clean, the new test passes (17/17 in
|
||||
`render-index-html.test.ts`), the CLI-registry suites pass (86/86), and the **full CI gate is
|
||||
green — 415 test files, 7855 tests, 0 failures**.
|
||||
|
||||
### Launch-surface registry integration — completed
|
||||
|
||||
The welcome screen, desktop Run menu and mobile Run picker now use the same injected CLI catalog.
|
||||
Every enabled registry entry is rendered; unavailable binaries remain hidden as before. Settings
|
||||
updates the catalog and availability flags in place after enable/disable, create, edit or delete,
|
||||
so the launch surfaces update without a page reload. A custom entry uses the generic quick-start
|
||||
path, while stock entries retain their existing per-CLI launch settings.
|
||||
|
||||
Not otherwise re-verified line-by-line against every Phase 1–6 checklist item below (e.g. the
|
||||
exact wording of toasts, the "same PR" sequencing notes) — the checklists are left as originally
|
||||
written; treat the **Status** section above as authoritative for what exists.
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`src/config/cli-registry/registry.ts` is READ-ONLY today, and says so in its own header comment:
|
||||
|
||||
> "⚠️ READ-ONLY. Nothing in this module writes, creates or migrates the file... there is no
|
||||
> settings UI and no write API yet... A `seededStockIds` ratchet belongs with the write API that
|
||||
> needs it."
|
||||
|
||||
Confirmed on `master` (2026-09-21): no `/api/clis` route exists at all (read or write);
|
||||
`~/.codeman/clis.json` is hand-edit-only; `resolveInstallCommandForPlatform()` is documented
|
||||
"Display text only — never executed" — nothing runs an install command server-side today. The
|
||||
original #343 review flagged the opposite (`spawn(command, {shell: true})`, `env.allowedPrefixes`
|
||||
contributed from a write) as needing its own trust-model decision; that decision was never made
|
||||
after the split, just dropped. This plan makes it.
|
||||
|
||||
**Closest existing precedent, and the template this plan follows for the read/write API**:
|
||||
`src/web/routes/custom-model-routes.ts` + `src/custom-model-hosts.ts` (#393/#430/#459) — a small
|
||||
per-item JSON store, Settings-UI-driven, admin-gated in multi-user mode, tmp+rename+0600 writes.
|
||||
|
||||
**Precedent for the new master feature flag (Phase 1)**: `customModelEndpointsEnabled` —
|
||||
`z.boolean().optional()` in `SettingsUpdateSchema` (`schemas.ts:1319`), a checkbox read/written by
|
||||
id in `openAppSettings()`/`saveAppSettings()` (`settings-ui.js:401`/`:2120`). SYNCED, not
|
||||
per-device (present in the schema, absent from `displayKeys`), default OFF.
|
||||
|
||||
**Spec refs for the whole plan:**
|
||||
- `src/config/cli-registry/registry.ts` — the read path; `resolveRegistry()`'s merge semantics
|
||||
(`deepMerge`, `UNMERGEABLE_KEYS`) apply unchanged to whatever this plan writes
|
||||
- `docs/cli-registry.md` — registry shape, "The override file", "Arg-template safety" (the four
|
||||
layers Phase 5's custom-entry validation must not weaken), "Adding a CLI" (the 5-step recipe a
|
||||
custom entry does NOT get to skip just because it arrives via UI instead of a stock.ts edit)
|
||||
- `src/web/routes/custom-model-routes.ts` + `src/custom-model-hosts.ts` — read/write API template
|
||||
- `docs/multi-user-plan.md`, `docs/security-architecture.md` — admin-gating conventions
|
||||
- `CLAUDE.md` §Multi-user mode, §"Settings surface", §"Per-device vs synced settings"
|
||||
|
||||
---
|
||||
|
||||
## Decisions (Phase 0, closed 2026-09-21)
|
||||
|
||||
1. **Enable/disable a stock CLI's `enabled` flag** — IN SCOPE. Plus a **master feature flag**
|
||||
(`cliManagementEnabled`, synced, default OFF) gating the whole Settings UI section's visibility,
|
||||
matching this codebase's standing convention for new admin-facing surfaces.
|
||||
2. **Auto-install** (stock CLIs' already-shipped, already-vetted install commands) — IN SCOPE,
|
||||
same PR.
|
||||
3. **Custom CLI entries via the UI** — IN SCOPE, **typed-argv only**: a custom entry goes through
|
||||
the exact same schema/argv-safety path stock entries do (named token patterns, no raw shell-text
|
||||
field). Its install command stays **display-only text**, same as every stock entry today — Phase
|
||||
4's auto-install NEVER executes a custom entry's install command, only a stock one's. This is
|
||||
the one place scope was deliberately narrowed relative to what was agreed in principle, because
|
||||
`docs/cli-registry.md`'s arg-template-safety section exists specifically to keep config free of
|
||||
shell text, and a free-text install command for a user-defined entry would reopen exactly that.
|
||||
4. **`shell`/`claude` un-disableable** — enforced at the **backend**, not just the UI (a
|
||||
frontend-only guard is bypassable with curl).
|
||||
5. **Non-admin visibility in multi-user mode** — the CLI-management Settings section is **hidden
|
||||
entirely** for a non-admin, not shown-empty.
|
||||
6. **`seededStockIds` ratchet** — not needed. `deepMerge()` only overrides a key the file actually
|
||||
sets, so a CLI absent from `clis.json.clis` always falls through to its stock `enabled` value
|
||||
with no special-casing. (Carried over from the first draft, not re-litigated.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Master feature flag: `cliManagementEnabled`
|
||||
|
||||
**Status:** DONE (commit `da07b38c`) — verified present in `SettingsUpdateSchema`, `index.html`,
|
||||
`openAppSettings()`/`saveAppSettings()`.
|
||||
|
||||
**Spec refs:**
|
||||
- `schemas.ts:1319` (`customModelEndpointsEnabled`) — the exact pattern to mirror: `z.boolean().optional()`
|
||||
in `SettingsUpdateSchema`
|
||||
- `settings-ui.js:401`/`:2120` — checkbox read/write by id in `openAppSettings()`/`saveAppSettings()`
|
||||
- `CLAUDE.md` §"Adding Features" → "App setting" — decide per-device vs synced FIRST (this one is
|
||||
synced: a feature toggle, not a display preference) and add to `displayKeys` NEVER for a synced
|
||||
setting
|
||||
|
||||
**Checklist:**
|
||||
- [x] Add `cliManagementEnabled: z.boolean().optional()` to `SettingsUpdateSchema`
|
||||
- [x] Add the checkbox to `index.html`'s `#settings-clis` section, above where Phase 6's per-CLI
|
||||
list will render — reads/writes via `openAppSettings()`/`saveAppSettings()` by id, same as
|
||||
`customModelEndpointsEnabled`
|
||||
- [x] `readCliManagementEnabled()` helper (mirrors `readCustomModelEndpointsEnabled()` in
|
||||
`custom-model-routes.ts:609`) for the route file(s) in Phases 2-5 to gate on
|
||||
- [x] When OFF: `GET /api/clis` still exists but the Settings UI section stays hidden
|
||||
(`applyCliManagementVisibility()`); the write endpoints reject (see Phase 3)
|
||||
|
||||
**Verify:** `npm run typecheck` passes; a unit test confirms `SettingsUpdateSchema` accepts/rejects
|
||||
the field correctly; toggling it in a fresh browser profile shows/hides the Settings section with
|
||||
no server restart.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Read endpoint: `GET /api/clis`
|
||||
|
||||
**Status:** DONE (commit `da07b38c`) — verified present in `src/web/routes/cli-registry-routes.ts`.
|
||||
|
||||
**Spec refs:**
|
||||
- `src/web/routes/custom-model-routes.ts:730` (`GET /api/model-endpoints`) — multi-user read
|
||||
gating: empty list for a non-admin, never a 403
|
||||
- `src/config/cli-registry/registry.ts` — `listClis()` (every entry, including disabled stock
|
||||
ones — this is an admin/settings surface, unlike `enabledClis()`)
|
||||
- `window.__codemanCliAvailable`'s resolvers (`isClaudeAvailable()` etc.) — candidate `installed`
|
||||
source; confirm whether to reuse directly or the response needs its own probe (Open Question 4,
|
||||
carried from the first draft — still genuinely open, decide during this phase not before)
|
||||
|
||||
**Checklist:**
|
||||
- [x] New route file `cli-registry-routes.ts`
|
||||
- [x] Response excludes `launch`/`env`/`capabilities`/`overlays`/`discovery`
|
||||
- [x] `isMultiUserMode() && !isAdmin(req)` → `[]`
|
||||
- [x] Unit tests in `test/routes/cli-registry-routes.test.ts` (admin/non-admin/single-user,
|
||||
disabled stock CLI still present)
|
||||
|
||||
**Verify:** `npm test -- test/routes/cli-registry-routes.test.ts` passes; `curl localhost:3000/api/clis | jq`
|
||||
shows every stock CLI including disabled ones.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Write endpoint: `PUT /api/clis/:id` (stock enable/disable)
|
||||
|
||||
**Status:** DONE (commit `db4557d9`) — `UNDISABLEABLE_IDS`, admin gate, tmp+rename+0600 all
|
||||
confirmed present.
|
||||
|
||||
**Spec refs:**
|
||||
- `src/web/routes/custom-model-routes.ts:753` + `src/custom-model-hosts.ts:91` — write-path
|
||||
template: `adminOnly` gate, read-modify-write the WHOLE file, tmp+rename+0600
|
||||
- `registry.ts:47` (`filePath()` = `dataPath(...)`) and `reloadCliRegistry()` — write to the same
|
||||
resolved path, invalidate the cache on every successful write or the change is invisible until
|
||||
restart
|
||||
|
||||
**Checklist:**
|
||||
- [x] Body: `{ enabled: boolean }`. Zod schema in `schemas.ts`
|
||||
- [x] Gate order: `cliManagementEnabled` → `adminOnly` → shell/claude guard → stock-only guard
|
||||
- [x] Rejects disabling `shell` or `claude` (`UNDISABLEABLE_IDS`)
|
||||
- [x] Rejects a write for an id that isn't a stock CLI
|
||||
- [x] Deep-merges `{ clis: { [id]: { enabled } } }`, preserving other override keys
|
||||
- [x] tmp+rename+0600 write, `reloadCliRegistry()` on success
|
||||
- [x] Unit tests (`test/routes/cli-registry-routes.test.ts`)
|
||||
|
||||
**Verify:** `npm test` full gate green; `curl -X PUT localhost:3000/api/clis/grok -d '{"enabled":false}'`
|
||||
then `GET /api/clis` shows the change with no restart; same against `shell`/`claude` returns an
|
||||
error and changes nothing; `ls -la ~/.codeman/clis.json` shows mode 0600.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Auto-install: `POST /api/clis/:id/install` (stock CLIs only)
|
||||
|
||||
**Status:** DONE (commit `db4557d9`) — route present, `appendAdminAudit` wired in.
|
||||
|
||||
**Spec refs:**
|
||||
- `registry.ts:231` (`resolveInstallCommandForPlatform`) — currently "Display text only — never
|
||||
executed"; this phase is what changes that, for stock entries only, with Decision 2's sign-off
|
||||
- Original #343 review's exact concern re: `env.allowedPrefixes` contributed from a write — stays
|
||||
out of scope; this phase only ever runs a command, never touches the env allowlist
|
||||
|
||||
**Checklist:**
|
||||
- [x] Separate endpoint from Phase 3's toggle
|
||||
- [x] Gate order: `cliManagementEnabled` → `adminOnly` → stock-entry-only guard
|
||||
- [x] `resolveInstallCommandForPlatform(entry)` for the target
|
||||
- [x] Bounded execution (timeout, captured stdout/stderr)
|
||||
- [x] Does NOT auto-enable on successful install
|
||||
- [x] Audit-logged via `appendAdminAudit`
|
||||
- [x] Unit tests
|
||||
|
||||
**Verify:** a real install triggered via the endpoint against a CLI not currently installed,
|
||||
`GET /api/clis`'s `installed` field flips true with no restart; audit log entry present; attempting
|
||||
install against a custom entry's id fails with a clear error; full CI gate green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Custom CLI entries: create / update / delete via API
|
||||
|
||||
**Status:** DONE (commit `db4557d9`) — `POST /api/clis`, `PUT /api/clis/custom/:id`,
|
||||
`DELETE /api/clis/:id` all present. Open Question 2 resolved: a **separate** endpoint
|
||||
(`PUT /api/clis/custom/:id`), not Phase 3's `PUT /api/clis/:id` widened.
|
||||
|
||||
**Spec refs:**
|
||||
- `docs/cli-registry.md` §"Arg-template safety" (all four layers), §"Adding a CLI" (the 5-step
|
||||
recipe) — a custom entry created via this API must satisfy the SAME schema (`CliEntrySchema`)
|
||||
every stock entry does; there is no relaxed path for UI-originated entries
|
||||
- `registry.ts`'s `resolveRegistry()` — the custom-entry branch (`stock: false`, dropped with a
|
||||
warning on validation failure, never falls back silently) already exists and is unchanged by
|
||||
this phase; this phase only adds a way to WRITE what that branch reads
|
||||
|
||||
**Checklist:**
|
||||
- [x] `POST /api/clis` (create), full `CliEntrySchema` validation
|
||||
- [x] `PUT /api/clis/custom/:id` (update) — separate endpoint from Phase 3's stock toggle
|
||||
- [x] `DELETE /api/clis/:id` refuses for any stock id
|
||||
- [x] `id` collision check against existing stock ids
|
||||
- [x] `discovery.install.command` on a custom entry stays DISPLAY-ONLY
|
||||
- [x] Same tmp+rename+0600 write pattern, `reloadCliRegistry()` on every successful mutation
|
||||
- [x] Unit tests
|
||||
|
||||
**Verify:** `npm test` full gate green; create a custom entry via curl, confirm it appears in
|
||||
`GET /api/clis` — **confirm it appears in the Run menu is UNVERIFIED and currently FALSE, see
|
||||
"Outstanding" above**; delete it, confirm it's gone and `clis.json` no longer references it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Settings UI
|
||||
|
||||
**Status:** DONE (commit `db4557d9`) — `#cliListGroup`, row rendering, toggle, Install button,
|
||||
custom-entry create/edit/delete form all present in `settings-ui.js`/`index.html`. Manual browser
|
||||
verification per the phase's own "Verify" step (flag on/off, non-admin hidden, toggle stops the
|
||||
Run menu offering a CLI, create/enable/launch a custom entry, delete it, shell/claude undisableable)
|
||||
has **not** been re-run in this session — the toggle→Run-menu leg specifically was BROKEN until the
|
||||
gotcha fix above, and the create→launch leg for a custom entry is the confirmed gap in
|
||||
"Outstanding".
|
||||
|
||||
**Spec refs:**
|
||||
- `index.html:2357` (`#settings-clis`) — the existing home; Phase 1's master toggle at the top,
|
||||
then the per-CLI list, then (if `cliManagementEnabled`) a "custom CLI" creation form, all above
|
||||
the existing Codex-only groups
|
||||
- `CLAUDE.md` §"Settings surface" — App Settings scrolls, it does not tab-switch
|
||||
- `admin-ui.js` — pattern for an admin-only-VISIBLE section (not just admin-only-writable),
|
||||
needed here per Decision 5
|
||||
|
||||
**Checklist:**
|
||||
- [x] Whole section hidden when `cliManagementEnabled` is OFF, and separately hidden for a
|
||||
non-admin in multi-user mode (`_applyCliManagementAdminGate`)
|
||||
- [x] Fetches `GET /api/clis` when the section becomes visible; renders one row per CLI
|
||||
- [x] Stock rows: enabled toggle only; `shell`/`claude` rows show the toggle disabled/greyed
|
||||
- [x] Custom rows: enabled toggle plus edit/delete affordances
|
||||
- [x] "Add custom CLI" form (id/label/badge/binary/argv)
|
||||
- [x] Toggle/edit/delete update the row in place
|
||||
|
||||
**Verify:** manual browser test per `CLAUDE.md`'s "Always Test Before Deploying" rule — **not yet
|
||||
re-run end-to-end in this session**; do this before considering the feature ready to ship, and
|
||||
expect the custom-entry-launch step to fail until the Outstanding gap above is closed.
|
||||
|
||||
---
|
||||
|
||||
## Remaining Open Questions
|
||||
|
||||
1. **Phase 2's `installed` source** — resolved: reuses `window.__codemanCliAvailable`'s existing
|
||||
resolvers via `GET /api/clis`'s own probe (confirmed by reading the route).
|
||||
2. **Phase 5's `PUT` endpoint shape** — resolved: a **separate** endpoint
|
||||
(`PUT /api/clis/custom/:id`), not Phase 3's toggle route widened.
|
||||
3. **Sequencing against the parked Copilot plan** — unchanged, still not blocking.
|
||||
4. **NEW: custom-CLI Run-menu integration** — see "Outstanding" above. Not decided or started.
|
||||
|
||||
---
|
||||
|
||||
Implementation is underway (see Status above); this line is left for history rather than removed —
|
||||
the plan was originally approved before Phases 1–6 landed.
|
||||
+31
-5
@@ -18,7 +18,17 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
|
||||
|
||||
## The override file
|
||||
|
||||
`~/.codeman/clis.json` (instance-scoped through `dataPath()`) holds overrides and custom entries only, never a copy of the stock catalog: `{ "clis": { "<id>": { ...partial entry... } } }`. Objects merge key-wise onto the stock entry, arrays replace wholesale. **The file must be mode 0600**; the loader refuses any group/world permission bit, read bits included, so a file created with a normal umask (0644) is ignored until you `chmod 600` it. Every reason a file was ignored or an entry dropped is logged once, prefixed `[cli-registry]`, on the first load. A stock entry whose override fails validation falls back to the shipped definition; a custom entry that fails is dropped. The file is read once per process and re-read only on restart.
|
||||
`~/.codeman/clis.json` (instance-scoped through `dataPath()`) holds overrides and custom entries only, never a copy of the stock catalog: `{ "clis": { "<id>": { ...partial entry... } } }`. Objects merge key-wise onto the stock entry, arrays replace wholesale. **The file must be mode 0600**; the loader refuses any group/world permission bit, read bits included, so a file created with a normal umask (0644) is ignored until you `chmod 600` it. Every reason a file was ignored or an entry dropped is logged once, prefixed `[cli-registry]`, on the first load. A stock entry whose override fails validation falls back to the shipped definition; a custom entry that fails is dropped. The file is read once per process and re-read after a change made through CLI management (below).
|
||||
|
||||
## Managing CLIs from Settings
|
||||
|
||||
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
|
||||
|
||||
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
|
||||
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
|
||||
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
|
||||
|
||||
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
|
||||
|
||||
## The shape of an entry
|
||||
|
||||
@@ -36,8 +46,9 @@ interface CliEntry {
|
||||
launch: CliLaunch; // the structured argv template
|
||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines? } — how
|
||||
// this CLI's pane shows work, and how it shows work it started in the background
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
|
||||
// — how this CLI's pane shows work, work it started in the background, and a turn
|
||||
// that ended waiting for workers it will resume from
|
||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||
}
|
||||
```
|
||||
@@ -46,7 +57,7 @@ interface CliEntry {
|
||||
|
||||
### Regexes that come from config
|
||||
|
||||
Three capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine` and `capabilities.workDetect.watchingLine`. All three go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
|
||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||
|
||||
@@ -56,6 +67,9 @@ agents` while a monitor, a backgrounded shell or a cloud session is live. Codema
|
||||
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
|
||||
so a pane waiting for its own background work never raises an alert a human cannot answer.
|
||||
Group 1 is the label, and a CLI that declares no pattern reports no background work.
|
||||
Claude's Artifact comment monitor is the one chip that does not count. It waits for a human
|
||||
to comment on a page the agent published, so Claude's pattern refuses any footer that
|
||||
carries it, and the idle alert goes out as usual.
|
||||
|
||||
Two CLIs declare such a row today, and they put it in different places. Claude writes its
|
||||
chip on the last row of the screen, so it keeps the default one-row window and anchors on
|
||||
@@ -66,6 +80,18 @@ entry declares `watchingLines: 3` and matches that row end to end. Both were mea
|
||||
against live panes rather than read out of a binary, which is the standard for adding a
|
||||
third.
|
||||
|
||||
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
|
||||
to wait for workers the CLI will resume from by itself. When background agents or an
|
||||
ultracode workflow are still running at turn end, Claude closes the turn with
|
||||
`✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, and a pane
|
||||
showing that row counts as working. ⚠️ Claude renders the row once and never redraws it, so
|
||||
the words are still on screen after the workers report back and the follow-up turn ends.
|
||||
The pattern is therefore never run over the whole pane: `isAwaitingWorkers()`
|
||||
(`session-activity.ts`) walks up from the composer past blank, framed and indented rows and
|
||||
tests only the first row that starts in column 0, which is the newest transcript row. Claude
|
||||
starts its own rows in column 0 and the agent's prose never does, so the anchor also keeps an
|
||||
agent from holding its own session busy.
|
||||
|
||||
That label is the one value in the registry that an AGENT can influence, because it comes off
|
||||
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
|
||||
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last few
|
||||
@@ -149,7 +175,7 @@ This matters because it is invisible when it is wrong. `capabilities.privilegedP
|
||||
|
||||
## Fields declared for later
|
||||
|
||||
`shortBadge`, `accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. They all describe frontend behaviour, and the frontend is deliberately untouched here: `app.js`, `terminal-ui.js` and `styles.css` keep their own hand-authored per-CLI rules, and moving them is its own piece of work verified by a browser/mobile suite the CI gate cannot see.
|
||||
`accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. (`shortBadge` was on this list until the CLI management list in Settings started showing it.) They all describe frontend behaviour, and the frontend is deliberately untouched here: `app.js`, `terminal-ui.js` and `styles.css` keep their own hand-authored per-CLI rules, and moving them is its own piece of work verified by a browser/mobile suite the CI gate cannot see.
|
||||
|
||||
Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, so re-measure before wiring one up. `accent` is the one exception: it was measured against styles.css on 2026-09-21 (method in the comment above `CLAUDE` in `stock.ts`), though nothing keeps it in step with the CSS either. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want.
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 w
|
||||
surfaces that same line as the install error. `npm install -g pnpm` (or
|
||||
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
|
||||
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
|
||||
alongside `dsh`.
|
||||
alongside `dsh`. The Compose server image (`docker/server.Dockerfile`) does not
|
||||
ship `dsh`, since it is installed at runtime, but it does ship pnpm so the UI
|
||||
button works there too.
|
||||
|
||||
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
|
||||
margin the most used community TUI, it is MIT, and it implements the status
|
||||
|
||||
@@ -83,6 +83,8 @@ Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.jso
|
||||
|
||||
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
|
||||
|
||||
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
|
||||
|
||||
## Quickest path: one-click "Run in Docker"
|
||||
|
||||
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
||||
|
||||
@@ -6,6 +6,8 @@ For the Compose configuration, environment settings, storage migration, and macv
|
||||
|
||||
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
|
||||
|
||||
CLIs installed from **App Settings → Agents & CLIs → CLI management** (DeepSeek Harness, Pi, and the other npm-based ones) go to `~/.local` on the `CODEMAN_APPDATA_PATH` mount, so they survive an image rebuild and a container recreate. Releases up to 1.33.1 installed them into the image instead, so a CLI installed from Settings on one of those has to be installed again once after the rebuild. The same applies to a hand-run `npm install -g` inside a session: it writes to the image prefix (`/opt/codeman-cli`) and is lost on the next rebuild, so use `npm install -g --prefix ~/.local <package>` instead.
|
||||
|
||||
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -66,6 +66,31 @@ each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
|
||||
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
||||
apply.
|
||||
|
||||
## Oversized input (issue #484)
|
||||
|
||||
Delivery has a third outcome besides "applied" and "retry": **refused for good**.
|
||||
Both transports refuse a frame longer than `MAX_INPUT_LENGTH` (64 KiB,
|
||||
`src/config/terminal-limits.ts`; the POST schema uses the same constant). Before
|
||||
#484 the client treated that like a transient failure, so an oversized paste sat
|
||||
at the head of the queue, was re-sent every 2 s forever, blocked every later
|
||||
input for the session, and came back from localStorage on each reload.
|
||||
|
||||
- `_sendInputAsync()` splits a paste over the frame limit into in-limit frames
|
||||
(`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They
|
||||
go out in seq order, so the PTY sees one contiguous stream. A paste over
|
||||
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never
|
||||
split), is refused with a toast and never queued.
|
||||
- The WebSocket answers an oversized sequenced frame with
|
||||
`{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A
|
||||
client that predates `err` reads it as a plain ACK and drops it too.
|
||||
- The POST drain drops a frame answered `400`/`413` (`401`/`403` stay transient:
|
||||
an expired login delivers once the user signs in again).
|
||||
- `_loadReliableState()` prunes persisted frames over the limit, so a queue
|
||||
poisoned by an older build heals on the first load after upgrading.
|
||||
- ⚠️ The frontend limit (`INPUT_FRAME_MAX_CHARS`) and the composer's
|
||||
`COMPOSER_INPUT_FRAME_LIMIT` must equal `MAX_INPUT_LENGTH`; pinned by
|
||||
`test/input-size-limit.test.ts`.
|
||||
|
||||
## Known limitation
|
||||
|
||||
Dedup state is in-memory on the server. A **server restart** between a write and
|
||||
@@ -79,3 +104,5 @@ across the narrow restart window.
|
||||
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
|
||||
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
|
||||
`(clientId, seq)` once on redelivery; untagged input always applies.
|
||||
- `test/input-size-limit.test.ts`: one input limit on both sides, frame
|
||||
splitting, and dropping (never retrying) a frame refused for good (#484).
|
||||
|
||||
@@ -42,10 +42,17 @@ npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run check:frontend-syntax
|
||||
npm run check:browser-excludes
|
||||
npm test -- test/<file>.test.ts # one file, the normal way
|
||||
npm run test:ci # the full CI sweep
|
||||
```
|
||||
|
||||
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
|
||||
machine-dependent) and blocks a push that would fail them. It skips itself when you push
|
||||
something other than the checked-out HEAD, or when the tree has uncommitted changes the
|
||||
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
||||
`pre-push` hook of your own is never overwritten.
|
||||
|
||||
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
||||
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
||||
fail on a normal machine. `test:ci` is the honest "run everything".
|
||||
|
||||
@@ -147,6 +147,12 @@ in `docker-compose.override.yml`), then rebuild the image with `--no-cache`.
|
||||
`docker/README.md` ("Private repositories") has the details and the matching switches for
|
||||
the server image.
|
||||
|
||||
To give agents a fixed Git commit identity, set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and
|
||||
`CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together in that same environment (in the Docker
|
||||
deployment, set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` instead, which feeds
|
||||
both images). An existing `codeman/agent:base` only picks it up after a `--no-cache` rebuild
|
||||
and recreated case containers; `docker/README.md` ("Git commit identity") has the details.
|
||||
|
||||
## Isolation
|
||||
|
||||
Every container runs hardened by default:
|
||||
|
||||
@@ -127,6 +127,10 @@ plain prose is not a dialog, so an agent that starts a monitor and then writes "
|
||||
should I target?" is quiet along with the rest — check a watching session yourself if it has
|
||||
been quiet longer than the work it is waiting for should take.
|
||||
|
||||
An agent waiting for your comments on an artifact it published never counts as watching.
|
||||
Claude shows that as "1 Artifact comment monitor", but the agent hears nothing until you
|
||||
comment, so the session alerts you like any other quiet session.
|
||||
|
||||
## The phone overview
|
||||
|
||||
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
|
||||
|
||||
@@ -151,10 +151,13 @@ Worth knowing:
|
||||
|
||||
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
|
||||
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
|
||||
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
|
||||
and automatic output recovery stay within the bounded browser buffer.
|
||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
|
||||
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
|
||||
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
|
||||
history; press **Load full history** to pull the rest explicitly. Automatic output
|
||||
recovery stays within the bounded browser buffer.
|
||||
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
|
||||
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
|
||||
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
|
||||
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
|
||||
always local scrollback. Other CLIs scroll locally.
|
||||
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
|
||||
`Ctrl+Shift+C` always copies.
|
||||
|
||||
@@ -164,8 +164,11 @@ Scrollback behaviour depends on the CLI, and Codeman adjusts what it strips per
|
||||
Things to try:
|
||||
|
||||
- `Shift+Wheel` always scrolls the local buffer, whatever else is going on.
|
||||
- On Claude sessions with a recent CLI, the wheel is forwarded into Claude's own transcript,
|
||||
so it scrolls the conversation rather than the terminal buffer. That is intended.
|
||||
- On Claude sessions running fullscreen (recent CLI with mouse tracking on), the wheel is
|
||||
forwarded into Claude's own transcript, so it scrolls the conversation rather than the
|
||||
terminal buffer. That is intended. Claude's default inline view scrolls locally; turn
|
||||
fullscreen on with `CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in
|
||||
`~/.claude/settings.json`.
|
||||
- Scrolling to the very top pulls the full tmux scrollback again on demand.
|
||||
|
||||
### The wheel does nothing in a Codex session
|
||||
|
||||
+1
-1
@@ -169,7 +169,7 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
|
||||
# commit as the script itself. Nothing fetched at install time is ever executed; there is
|
||||
# no network refresh of these arrays. See cli_catalog_select_platform below.
|
||||
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp')
|
||||
CLI_LABELS=('Claude' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP')
|
||||
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP')
|
||||
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1)
|
||||
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0)
|
||||
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh')
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.2",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -28,6 +28,7 @@
|
||||
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
|
||||
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
|
||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.2",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -340,7 +340,7 @@ ESC=$(printf '\033')
|
||||
### Starting a worker
|
||||
|
||||
`POST /api/v1/quick-start` body (all optional):
|
||||
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
|
||||
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
||||
`.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.
|
||||
|
||||
@@ -101,11 +101,16 @@ the case name, read it from the listing.
|
||||
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
|
||||
CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at
|
||||
`tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name
|
||||
(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`,
|
||||
and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's
|
||||
(verified live: a quick-start `sessionName` is listed as that exact peer name, and
|
||||
the worker's messages arrive tagged `from-name="<that name>"`; a derived-name worker's
|
||||
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
|
||||
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
|
||||
peer name stays derived. The flag is fail-closed (older/unknown CLI omits it, because an
|
||||
peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose
|
||||
is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's
|
||||
`/resume` title and terminal title and suppresses Claude's own generated title. A
|
||||
placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`)
|
||||
and an auto name are NOT passed, so such a worker's peer name is derived; use
|
||||
`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an
|
||||
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
|
||||
only unsafe characters is dropped), and the docker/remote builders never see it at all
|
||||
(`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key
|
||||
@@ -196,8 +201,8 @@ idle:
|
||||
The contract an orchestrator follows for any fleet of two or more messaging workers.
|
||||
Every topology in the next section is this protocol plus a wiring diagram.
|
||||
|
||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
|
||||
`--name` gate above). Session create installs the hooks block into the workspace
|
||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with a descriptive,
|
||||
non-`w<N>-` `sessionName` (the `--name` gate above). Session create installs the hooks block into the workspace
|
||||
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
|
||||
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
|
||||
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
|
||||
|
||||
@@ -692,8 +692,9 @@ The shape, each step verified live (probes, failure modes and safety detail in
|
||||
[§5.2](#52-readiness)).
|
||||
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
|
||||
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
|
||||
2.1.224+ a worker's peer name is its Codeman session name, so pass `sessionName`
|
||||
in quick-start to pick it; older setups list a name derived from the case folder.
|
||||
2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE
|
||||
`sessionName` in quick-start to pick it (a `w<N>-` placeholder-shaped name is not
|
||||
pinned, so it lists derived); older setups list a name derived from the case folder.
|
||||
No row = messaging is off for that worker (it is feature-flagged even on matching
|
||||
CLI versions, observed live): fall back to the HTTP recipes without complaint.
|
||||
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Browser-test exclusion check.
|
||||
*
|
||||
* `npm run test:ci` must never try to drive a real browser: CI runners (and any
|
||||
* clean checkout) have no chromium, so such a file dies with
|
||||
* `browserType.launch: Executable doesn't exist` and takes the whole suite with
|
||||
* it. `config/vitest.ci.config.ts` therefore excludes every browser-driven test
|
||||
* via `BROWSER_TEST_GLOBS` in `config/test-suites.ts`. That list is maintained
|
||||
* BY HAND, and a new browser test simply does not appear in it unless someone
|
||||
* remembers. The omission is invisible on a developer machine that has run
|
||||
* `npx playwright install`, where the test passes, and only shows up on a clean
|
||||
* runner.
|
||||
*
|
||||
* Two deliberate design choices:
|
||||
*
|
||||
* 1. **Detection is by CONTENT, not filename.** Matching `*.browser.test.ts`
|
||||
* would miss the browser tests that predate that convention
|
||||
* (`inline-rename`, `opencode-resize`, `webgl-fallback`,
|
||||
* `terminal-copy-shortcut`, `codex-predictive-echo`). What actually makes a
|
||||
* file dangerous is importing a browser driver, so that is what is tested.
|
||||
* ⚠️ Only a DIRECT import is seen: a test that reaches playwright through a
|
||||
* helper module (e.g. `test/mobile/helpers/browser.ts`) is not detected, so
|
||||
* such a test still has to be added to `BROWSER_TEST_GLOBS` by hand.
|
||||
*
|
||||
* 2. **The exclusion side is answered by vitest itself**, via
|
||||
* `vitest list --filesOnly`, rather than by re-implementing glob matching
|
||||
* against the config's `exclude` array. Patterns there include `test/mobile/**`
|
||||
* and `perf-*`; a hand-rolled matcher that disagreed with vitest by even one
|
||||
* edge case would report a gap that does not exist, or miss one that does.
|
||||
* Asking the real resolver cannot drift from the real behaviour.
|
||||
*
|
||||
* The pure pieces are exported for test/check-browser-test-excludes.test.ts; the
|
||||
* check itself only runs when this file is executed directly.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, relative, sep, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CI_CONFIG = join('config', 'vitest.ci.config.ts');
|
||||
const SUITES_FILE = join('config', 'test-suites.ts');
|
||||
|
||||
/** Importing any one of these means the test needs a real browser binary. */
|
||||
const BROWSER_DRIVER =
|
||||
/\bfrom\s+['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]|\b(?:require|import)\(\s*['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]\s*\)/;
|
||||
|
||||
/** @param {string} source */
|
||||
export function importsBrowserDriver(source) {
|
||||
return BROWSER_DRIVER.test(source);
|
||||
}
|
||||
|
||||
/** @param {string} dir @returns {string[]} */
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walk(path));
|
||||
else if (entry.isFile() && entry.name.endsWith('.test.ts')) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `*.test.ts` under `<root>/test`, as sorted repo-relative POSIX paths (the form
|
||||
* `vitest list` prints).
|
||||
*
|
||||
* @param {string} root
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function findTestFiles(root) {
|
||||
return walk(join(root, 'test'))
|
||||
.map((file) => relative(root, file).split(sep).join('/'))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of {@link findTestFiles} that imports a browser driver.
|
||||
*
|
||||
* @param {string} root
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function findBrowserTests(root) {
|
||||
return findTestFiles(root).filter((file) => importsBrowserDriver(readFileSync(join(root, file), 'utf8')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `vitest list --filesOnly` output into a set of repo-relative paths. Stray
|
||||
* blank or decorative lines are ignored rather than assuming the format is pristine.
|
||||
*
|
||||
* @param {string} output
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function parseVitestFileList(output) {
|
||||
return new Set(
|
||||
output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.endsWith('.test.ts'))
|
||||
.map((line) => line.replace(/^\.\//, ''))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the `vitest list` paths and the walked tree name at least one file in common.
|
||||
* False means the two sides are not speaking the same path format (absolute paths, backslashes
|
||||
* or a new prefix after a vitest upgrade), and then {@link findLeaks} would find nothing
|
||||
* against a perfectly non-empty listing.
|
||||
*
|
||||
* @param {Set<string>} ciFiles
|
||||
* @param {string[]} testFiles
|
||||
*/
|
||||
export function listingMatchesTree(ciFiles, testFiles) {
|
||||
return testFiles.some((file) => ciFiles.has(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} browserTests
|
||||
* @param {Set<string>} ciFiles
|
||||
* @returns {string[]} browser-driven files that the CI config would still collect
|
||||
*/
|
||||
export function findLeaks(browserTests, ciFiles) {
|
||||
return browserTests.filter((file) => ciFiles.has(file));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const testFiles = findTestFiles(ROOT);
|
||||
const browserTests = findBrowserTests(ROOT);
|
||||
|
||||
let collected;
|
||||
try {
|
||||
collected = execFileSync('npx', ['vitest', 'list', '--config', CI_CONFIG, '--filesOnly'], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('✗ could not enumerate the CI test set via `vitest list`.');
|
||||
console.error(err.stderr ? err.stderr.toString() : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ciFiles = parseVitestFileList(collected);
|
||||
if (ciFiles.size === 0) {
|
||||
// An empty list would make every browser test look excluded: fail rather than pass vacuously.
|
||||
console.error('✗ `vitest list` reported no test files; refusing to pass on an empty CI set.');
|
||||
process.exit(1);
|
||||
}
|
||||
// Same vacuous pass, one step removed: a listing whose paths never match the tree. This guard,
|
||||
// not `vitest list --json`, is the answer to format drift: the JSON form prints absolute paths
|
||||
// that would need canonicalizing against ROOT (symlinked checkouts), and its shape can drift too.
|
||||
if (!listingMatchesTree(ciFiles, testFiles)) {
|
||||
const sample = [...ciFiles].slice(0, 3).join(', ');
|
||||
console.error(
|
||||
`✗ none of the ${ciFiles.size} paths \`vitest list\` reported (e.g. ${sample}) is one of the ${testFiles.length} test/**/*.test.ts files; its output format has probably changed.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const leaked = findLeaks(browserTests, ciFiles);
|
||||
|
||||
if (leaked.length > 0) {
|
||||
console.error(`✗ ${leaked.length} browser-driven test file(s) are NOT excluded from ${CI_CONFIG}:\n`);
|
||||
for (const file of leaked) console.error(` ${file}`);
|
||||
console.error(`
|
||||
These import a browser driver, so on a runner with no chromium they fail with
|
||||
"browserType.launch: Executable doesn't exist" and take the suite down. Add each
|
||||
to BROWSER_TEST_GLOBS in ${SUITES_FILE} (${CI_CONFIG} derives its excludes from
|
||||
it, and \`npm run test:browser\` its includes).
|
||||
|
||||
They may well pass on this machine; that is the trap. To reproduce a clean
|
||||
runner locally:
|
||||
PLAYWRIGHT_BROWSERS_PATH=\$(mktemp -d) PUPPETEER_CACHE_DIR=\$(mktemp -d) npm run test:ci`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✓ all ${browserTests.length} browser-driven test files are excluded from the CI suite (${ciFiles.size} files collected)`
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @fileoverview Git hook bodies + install policy, shared by scripts/postinstall.js and
|
||||
* pinned by test/git-hooks.test.ts.
|
||||
*
|
||||
* Why a pre-push hook: the static CI job (lockfile, typecheck, lint, format, frontend
|
||||
* syntax, ...) fails often on things a contributor could have caught locally in seconds,
|
||||
* and finding out after a push costs a full CI round-trip plus a fix-up commit. Running
|
||||
* the same checks before the push surfaces those failures in ~10-40s instead (12s on a fast
|
||||
* workstation, ~35s measured elsewhere; typecheck, format:check and lint dominate).
|
||||
*
|
||||
* Why pre-PUSH and not pre-commit: a commit is cheap and local, a push is what CI and
|
||||
* reviewers pick up. And why the STATIC tier only: the unit/integration suite takes
|
||||
* minutes, which nobody tolerates per push, so a hook that ran it would be bypassed
|
||||
* within a day. The checks below mirror the static CI job.
|
||||
*
|
||||
* ⚠️ The checks read the WORKING TREE, not the commits being pushed. So the hook skips
|
||||
* (with a one-line notice) whenever the two can differ: when HEAD is not the commit being
|
||||
* pushed, and when `git status` shows uncommitted or untracked changes in a path a check
|
||||
* reads ({@link PRE_PUSH_WATCHED_PATHS}). In a checkout shared by several agent sessions
|
||||
* the second case is usually another session's WIP, which must not block this push.
|
||||
*
|
||||
* ⚠️ This installer is deliberately MARKER-OWNED, unlike the older pre-commit installer in
|
||||
* postinstall.js which overwrites whatever it finds. A developer's own pre-push hook must
|
||||
* survive `npm install`.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Ownership marker. ⚠️ Never bump the version suffix: ownership is matched on this exact
|
||||
* string, so a `v2` would read every installed `v1` hook as foreign and never refresh it.
|
||||
* A changed body still reaches installed hooks, because the refresh compares the whole file.
|
||||
*/
|
||||
export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1';
|
||||
|
||||
/**
|
||||
* Checks that make up the fast tier, cheapest first so failures surface sooner. Each entry
|
||||
* is the argument list for `npm run`, and each is a step of the static job in
|
||||
* .github/workflows/ci.yml (test/git-hooks.test.ts pins that every script exists).
|
||||
*/
|
||||
export const PRE_PUSH_CHECKS = [
|
||||
['check:lockfile'],
|
||||
['generate:cli-catalog', '--', '--check'],
|
||||
['check:browser-excludes'],
|
||||
['check:frontend-syntax'],
|
||||
['format:check'],
|
||||
['lint'],
|
||||
['typecheck'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Paths whose uncommitted state would leak into a check, so a dirty one makes the hook skip.
|
||||
* Derived from what each check reads: src/ (format:check, lint, typecheck,
|
||||
* check:frontend-syntax), config/ (eslint + vitest configs, test-suites.ts, the CLI
|
||||
* catalogue), scripts/ (every check is a script there, and typecheck's second pass compiles
|
||||
* one), test/ (check:browser-excludes scans it and runs `vitest list` over it),
|
||||
* package.json + package-lock.json (check:lockfile), install.sh (generate:cli-catalog
|
||||
* --check diffs its generated block), tsconfig.json (typecheck, and
|
||||
* config/tsconfig.scripts.json extends it) and .prettierignore + .editorconfig
|
||||
* (format:check; the Prettier CLI honours .editorconfig by default).
|
||||
*/
|
||||
export const PRE_PUSH_WATCHED_PATHS = [
|
||||
'src',
|
||||
'config',
|
||||
'scripts',
|
||||
'test',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'install.sh',
|
||||
'tsconfig.json',
|
||||
'.prettierignore',
|
||||
'.editorconfig',
|
||||
];
|
||||
|
||||
/**
|
||||
* Render the pre-push hook script.
|
||||
*
|
||||
* POSIX sh, not bash: this ships to whatever shell the contributor's git uses.
|
||||
*/
|
||||
export function renderPrePushHook() {
|
||||
const runs = PRE_PUSH_CHECKS.map((args) => `run_check ${args.join(' ')}`).join('\n');
|
||||
const watched = PRE_PUSH_WATCHED_PATHS.join(' ');
|
||||
|
||||
return `#!/bin/sh
|
||||
${PRE_PUSH_MARKER}
|
||||
# Installed by scripts/postinstall.js. Edit scripts/git-hooks.mjs, not this file:
|
||||
# it is regenerated on npm install. Delete the marker line above to take ownership
|
||||
# and the installer will leave your version alone.
|
||||
#
|
||||
# Skip once: CODEMAN_SKIP_PREPUSH=1 git push
|
||||
# Skip always: remove this file.
|
||||
|
||||
[ "$CODEMAN_SKIP_PREPUSH" = "1" ] && exit 0
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
cd "$repo_root" || exit 0
|
||||
|
||||
# Nothing to check without dependencies (fresh clone, or a worktree that never ran
|
||||
# npm install). Warn rather than blocking the push on a setup detail.
|
||||
if [ ! -d node_modules ]; then
|
||||
echo "pre-push: node_modules missing, skipping checks (run 'npm install' to enable them)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# GUI git clients and IDEs often run hooks with a minimal PATH that lacks an nvm or
|
||||
# Homebrew Node. Every check would then fail with "npm: not found", so skip instead.
|
||||
command -v npm >/dev/null 2>&1 || { echo "pre-push: npm not on PATH, skipping checks."; exit 0; }
|
||||
|
||||
# git feeds us "<localref> <localsha> <remoteref> <remotesha>" per ref. A deletion has an
|
||||
# all-zero local sha and no tree worth checking; if every ref is a deletion, skip.
|
||||
# The checks below read the working tree, so they only say something about a pushed commit
|
||||
# that IS the checked-out HEAD (tags are peeled to their commit first).
|
||||
head=$(git rev-parse -q --verify HEAD 2>/dev/null)
|
||||
has_content=0
|
||||
not_head=''
|
||||
while read -r localref localsha _remoteref _remotesha; do
|
||||
[ -z "$localsha" ] && continue
|
||||
case "$localsha" in
|
||||
0000000000000000000000000000000000000000) ;;
|
||||
*)
|
||||
has_content=1
|
||||
commit=$(git rev-parse -q --verify "$localsha^{commit}" 2>/dev/null)
|
||||
[ -n "$head" ] && [ "$commit" = "$head" ] || not_head="$localref"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[ "$has_content" = "0" ] && exit 0
|
||||
|
||||
if [ -n "$not_head" ]; then
|
||||
echo "pre-push: skipping static checks: $not_head is not the checked-out HEAD, and the checks read the working tree."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Uncommitted or untracked changes in a path a check reads would be judged instead of the
|
||||
# pushed commit. In a checkout shared by several sessions that is usually someone else's WIP.
|
||||
if [ -n "$(git --no-optional-locks status --porcelain -- ${watched} 2>/dev/null)" ]; then
|
||||
echo "pre-push: skipping static checks: uncommitted changes under ${watched} would be checked instead of the pushed commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log=$(mktemp "\${TMPDIR:-/tmp}/codeman-prepush.XXXXXX") || exit 0
|
||||
trap 'rm -f "$log"' EXIT
|
||||
|
||||
failed=''
|
||||
run_check() {
|
||||
if ! npm run --silent "$@" >"$log" 2>&1; then
|
||||
echo ""
|
||||
echo "pre-push: FAILED npm run $*"
|
||||
tail -n 25 "$log"
|
||||
failed="$failed $1"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "pre-push: running static checks (~10-40s)..."
|
||||
${runs}
|
||||
|
||||
if [ -n "$failed" ]; then
|
||||
echo ""
|
||||
echo "pre-push: blocked by:$failed"
|
||||
echo "Fix, or push anyway with: CODEMAN_SKIP_PREPUSH=1 git push"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-push: static checks passed."
|
||||
exit 0
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to do with an existing hook file.
|
||||
*
|
||||
* @param {{ existing: string | null | undefined, next: string }} args
|
||||
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
|
||||
*/
|
||||
export function planHookInstall({ existing, next }) {
|
||||
if (existing === null || existing === undefined || existing.trim() === '') return 'write';
|
||||
if (!existing.includes(PRE_PUSH_MARKER)) return 'skip-foreign';
|
||||
return existing === next ? 'up-to-date' : 'write';
|
||||
}
|
||||
|
||||
/** @param {string} cwd @param {string[]} args */
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* realpath() that tolerates a missing leaf: a fresh `.git` may have no `hooks/` yet, so
|
||||
* canonicalize the parent and re-append the name. Throws if the parent is missing too.
|
||||
*
|
||||
* @param {string} path
|
||||
*/
|
||||
function canonicalPath(path) {
|
||||
return existsSync(path) ? realpathSync(path) : join(realpathSync(dirname(path)), basename(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the hooks directory for the checkout rooted at `repoRoot`, or null when there
|
||||
* is nothing to install into.
|
||||
*
|
||||
* Asks git (`--git-path hooks`) rather than assuming `<root>/.git/hooks`: in a worktree
|
||||
* `.git` is a FILE pointing at the parent repo, so the hooks live under
|
||||
* `--git-common-dir`.
|
||||
*
|
||||
* ⚠️ Returns a directory ONLY when it is this repository's own `<git-common-dir>/hooks`.
|
||||
* `--git-path hooks` also reports `core.hooksPath`, and that setting is often GLOBAL (a
|
||||
* shared hooks directory used by every repo on the machine); installing there would
|
||||
* overwrite the user's own hooks and run Codeman's checks on unrelated repos. A
|
||||
* `core.hooksPath` that points back at the repo's own hooks dir still resolves, because
|
||||
* the comparison is on canonical paths rather than on whether the setting exists.
|
||||
*
|
||||
* Also returns null unless `repoRoot` is itself the top of a work tree. Without that guard,
|
||||
* a copy of this package sitting inside SOMEONE ELSE's repository (e.g. under their
|
||||
* node_modules) would resolve to their hooks directory and install Codeman's hook there.
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function resolveGitHooksDir(repoRoot) {
|
||||
try {
|
||||
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
|
||||
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
|
||||
// Both are printed relative to the cwd (repoRoot) unless already absolute.
|
||||
const hooks = git(repoRoot, ['rev-parse', '--git-path', 'hooks']);
|
||||
const common = git(repoRoot, ['rev-parse', '--git-common-dir']);
|
||||
if (!hooks || !common) return null;
|
||||
const own = join(realpathSync(resolve(repoRoot, common)), 'hooks');
|
||||
return canonicalPath(resolve(repoRoot, hooks)) === own ? own : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install (or refresh) the managed pre-push hook in `hooksDir`, honouring
|
||||
* {@link planHookInstall}: a hook without the marker is never touched.
|
||||
*
|
||||
* @param {string} hooksDir
|
||||
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
|
||||
*/
|
||||
export function installPrePushHook(hooksDir) {
|
||||
const path = join(hooksDir, 'pre-push');
|
||||
const next = renderPrePushHook();
|
||||
const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
||||
const action = planHookInstall({ existing, next });
|
||||
if (action === 'write') {
|
||||
mkdirSync(hooksDir, { recursive: true });
|
||||
writeFileSync(path, next, { mode: 0o755 });
|
||||
chmodSync(path, 0o755); // `mode` only applies when the file is created
|
||||
}
|
||||
return action;
|
||||
}
|
||||
@@ -64,6 +64,15 @@ export const GIT_HOST_CLI_BUILD_ARGS = [
|
||||
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Environment variable → Dockerfile ARG for the image's system Git identity.
|
||||
* ⚠️ Mirrored by `GIT_IDENTITY_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
|
||||
*/
|
||||
export const GIT_IDENTITY_BUILD_ARGS = [
|
||||
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
|
||||
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
|
||||
];
|
||||
|
||||
/**
|
||||
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
||||
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
||||
@@ -82,9 +91,25 @@ export function gitHostCliBuildArgPairs(env) {
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs for Git identity, requiring either both values or neither. */
|
||||
export function gitIdentityBuildArgPairs(env) {
|
||||
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? '']);
|
||||
const configured = pairs.filter(([, value]) => value !== '');
|
||||
if (configured.length === 0) return [];
|
||||
if (configured.length !== pairs.length) {
|
||||
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
|
||||
throw new Error(`${names} must both be set when configuring Git identity`);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
||||
export function agentImageBuildArgPairs(catalog, env = process.env) {
|
||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')], ...gitHostCliBuildArgPairs(env)];
|
||||
return [
|
||||
['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')],
|
||||
...gitHostCliBuildArgPairs(env),
|
||||
...gitIdentityBuildArgPairs(env),
|
||||
];
|
||||
}
|
||||
|
||||
/** Read the committed catalogue. IO. */
|
||||
|
||||
+16
-4
@@ -356,14 +356,17 @@ if (!isGlobalInstall) {
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 5. Install git pre-commit hook (format check)
|
||||
// 5. Install git hooks (pre-commit format check, pre-push static checks)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
if (!isGlobalInstall) {
|
||||
try {
|
||||
const { writeFileSync, mkdirSync } = await import('fs');
|
||||
const gitHooksDir = join(import.meta.dirname, '..', '.git', 'hooks');
|
||||
if (existsSync(join(import.meta.dirname, '..', '.git'))) {
|
||||
const { resolveGitHooksDir, installPrePushHook } = await import('./git-hooks.mjs');
|
||||
// Resolved through git, not `../.git/hooks`: in a worktree `.git` is a file.
|
||||
// null when this directory is not the top of a git checkout.
|
||||
const gitHooksDir = resolveGitHooksDir(join(import.meta.dirname, '..'));
|
||||
if (gitHooksDir) {
|
||||
mkdirSync(gitHooksDir, { recursive: true });
|
||||
const hook = `#!/bin/bash
|
||||
# Auto-installed by postinstall — prevents CI format failures
|
||||
@@ -379,9 +382,18 @@ fi
|
||||
const hookPath = join(gitHooksDir, 'pre-commit');
|
||||
writeFileSync(hookPath, hook, { mode: 0o755 });
|
||||
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
|
||||
|
||||
// Unlike the pre-commit hook above, this one is marker-owned: a pre-push
|
||||
// hook the developer wrote themselves is left alone.
|
||||
const action = installPrePushHook(gitHooksDir);
|
||||
if (action === 'write') {
|
||||
console.log(colors.green('✓ Git pre-push hook installed') + colors.dim(' (static CI checks, ~10-40s)'));
|
||||
} else if (action === 'skip-foreign') {
|
||||
console.log(colors.dim(' Existing pre-push hook left untouched (not Codeman-managed)'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — git hook is a convenience
|
||||
// Non-critical — git hooks are a convenience
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-1
@@ -74,6 +74,15 @@ echo "[self-update] $(date) start tag=$TAG supervisor=$SUPERVISOR repo=$REPO"
|
||||
export PATH="$(dirname "$NODE"):$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
# --node is the server's process.execPath, a VERSIONED path (Homebrew resolves it
|
||||
# into Cellar/node/<ver>/). A `brew upgrade node` under a long-running server
|
||||
# deletes it, and every status write then failed, so the status stayed "queued"
|
||||
# forever. Fall back to whatever node is on PATH.
|
||||
if [ ! -x "$NODE" ]; then
|
||||
echo "[self-update] WARN: $NODE is not executable, falling back to node on PATH"
|
||||
NODE="$(command -v node || echo node)"
|
||||
fi
|
||||
|
||||
TO_VERSION="${TAG##*@}" # codeman@0.9.4 → 0.9.4 (tag is validated upstream)
|
||||
STASH_REF=""
|
||||
MANUAL_CMD=""
|
||||
@@ -276,7 +285,17 @@ case "$SUPERVISOR" in
|
||||
# domain needs root, but we don't need it — kill the server and launchd
|
||||
# respawns it on the new dist/ within ThrottleInterval seconds.
|
||||
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
|
||||
: # respawn is launchd's job from here
|
||||
# Respawn is launchd's job, but only once the old process EXITS. A graceful
|
||||
# shutdown that hangs leaves the port closed and the service down, so
|
||||
# escalate to SIGKILL (tmux sessions live outside the server and survive).
|
||||
for _ in $(seq 1 30); do
|
||||
kill -0 "$SERVER_PID" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
echo "[self-update] server pid $SERVER_PID still alive 30s after SIGTERM, sending SIGKILL"
|
||||
kill -9 "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
MANUAL_CMD="sudo launchctl kickstart -k system/com.codeman.web"
|
||||
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
||||
|
||||
@@ -340,7 +340,7 @@ ESC=$(printf '\033')
|
||||
### Starting a worker
|
||||
|
||||
`POST /api/v1/quick-start` body (all optional):
|
||||
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
|
||||
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
||||
`.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.
|
||||
|
||||
@@ -101,11 +101,16 @@ the case name, read it from the listing.
|
||||
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
|
||||
CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at
|
||||
`tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name
|
||||
(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`,
|
||||
and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's
|
||||
(verified live: a quick-start `sessionName` is listed as that exact peer name, and
|
||||
the worker's messages arrive tagged `from-name="<that name>"`; a derived-name worker's
|
||||
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
|
||||
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
|
||||
peer name stays derived. The flag is fail-closed (older/unknown CLI omits it, because an
|
||||
peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose
|
||||
is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's
|
||||
`/resume` title and terminal title and suppresses Claude's own generated title. A
|
||||
placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`)
|
||||
and an auto name are NOT passed, so such a worker's peer name is derived; use
|
||||
`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an
|
||||
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
|
||||
only unsafe characters is dropped), and the docker/remote builders never see it at all
|
||||
(`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key
|
||||
@@ -196,8 +201,8 @@ idle:
|
||||
The contract an orchestrator follows for any fleet of two or more messaging workers.
|
||||
Every topology in the next section is this protocol plus a wiring diagram.
|
||||
|
||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
|
||||
`--name` gate above). Session create installs the hooks block into the workspace
|
||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with a descriptive,
|
||||
non-`w<N>-` `sessionName` (the `--name` gate above). Session create installs the hooks block into the workspace
|
||||
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
|
||||
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
|
||||
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
|
||||
|
||||
@@ -692,8 +692,9 @@ The shape, each step verified live (probes, failure modes and safety detail in
|
||||
[§5.2](#52-readiness)).
|
||||
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
|
||||
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
|
||||
2.1.224+ a worker's peer name is its Codeman session name, so pass `sessionName`
|
||||
in quick-start to pick it; older setups list a name derived from the case folder.
|
||||
2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE
|
||||
`sessionName` in quick-start to pick it (a `w<N>-` placeholder-shaped name is not
|
||||
pinned, so it lists derived); older setups list a name derived from the case folder.
|
||||
No row = messaging is off for that worker (it is feature-flagged even on matching
|
||||
CLI versions, observed live): fall back to the HTTP recipes without complaint.
|
||||
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @fileoverview Carry a Codeman rename into Claude Code's own session title.
|
||||
*
|
||||
* Claude Code keeps a conversation's title in its transcript as a
|
||||
* `{"type":"custom-title"}` row (what `/rename` writes), last row wins, and the
|
||||
* `/resume` picker shows `customTitle ?? aiTitle`. Renaming a tab in Codeman
|
||||
* used to change only the tab, so `/resume` kept listing the old name.
|
||||
*
|
||||
* Appending the row is enough for a pane that was spawned WITHOUT `--name`
|
||||
* (every placeholder- or auto-named tab, see `Session.cliPinnedName`): that
|
||||
* process holds no title of its own and never writes one back. A process that
|
||||
* WAS spawned with `--name` re-appends its in-memory title after each turn, so
|
||||
* there the new title holds from the next spawn, which pins the new name.
|
||||
*
|
||||
* @module claude-session-title
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Append a `custom-title` row for `conversationId` to an existing transcript.
|
||||
* Never creates the file: a missing transcript means the conversation has not
|
||||
* been written yet, and a file of only a title row would show up in `/resume`
|
||||
* as an empty conversation. Returns whether a row was written.
|
||||
*/
|
||||
export async function appendClaudeCustomTitle(
|
||||
transcriptPath: string,
|
||||
conversationId: string,
|
||||
title: string
|
||||
): Promise<boolean> {
|
||||
const customTitle = title.trim();
|
||||
// Claude reads the row through `customTitle ?? aiTitle`, so an empty string
|
||||
// would blank the picker entry rather than fall back to the generated title.
|
||||
if (!customTitle) return false;
|
||||
try {
|
||||
if (!(await fs.stat(transcriptPath)).isFile()) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// One O_APPEND write of one line, the same way Claude appends its own rows,
|
||||
// so it cannot interleave with a row the live process is writing.
|
||||
const row = JSON.stringify({ type: 'custom-title', customTitle, sessionId: conversationId });
|
||||
await fs.appendFile(transcriptPath, `${row}\n`);
|
||||
return true;
|
||||
}
|
||||
+10
@@ -129,6 +129,8 @@ program
|
||||
|
||||
/** Same registry the server resolves case names through (mirrors `case-routes.ts`). */
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
/** Graceful shutdown budget before the process force-exits (see the SIGTERM handler). */
|
||||
const SHUTDOWN_FORCE_EXIT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Case name to directory, checking `linked-cases.json` FIRST and falling back to the
|
||||
@@ -1002,6 +1004,14 @@ webCmd.action(async (options) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(palette.warn(`\n${signal} received, shutting down gracefully...`));
|
||||
// A hung stop() must not keep the process alive: the listener is already
|
||||
// closed by then, and a KeepAlive LaunchDaemon only respawns the server once
|
||||
// it EXITS (systemd would SIGKILL after TimeoutStopSec; launchd does not).
|
||||
// Seen after a self-update on macOS: port closed, process alive, service down.
|
||||
setTimeout(() => {
|
||||
console.error(palette.err(`Shutdown did not finish in ${SHUTDOWN_FORCE_EXIT_MS / 1000}s, forcing exit`));
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_FORCE_EXIT_MS).unref();
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @fileoverview Write side of the CLI registry (docs/cli-enable-disable-plan.md, Phases 3/5).
|
||||
*
|
||||
* Kept deliberately SEPARATE from `registry.ts`, whose reading path does no writes on import
|
||||
* (`schemas.ts` imports it, transitively). Only `cli-registry-routes.ts` imports this module,
|
||||
* so that property still holds for every OTHER importer of the registry.
|
||||
*
|
||||
* Every mutation goes through `mutateRegistryFile()`, which does three things the #476 review
|
||||
* found missing:
|
||||
*
|
||||
* - **Serialized.** Mutations run one at a time on a single promise chain, and each one
|
||||
* reads, changes, writes and reloads before the next starts. Unserialized read-modify-write
|
||||
* lost toggles when three `PUT /api/clis/:id` calls ran in parallel.
|
||||
* - **Refuses a file it must not trust.** The reader ignores a `clis.json` with any
|
||||
* group/world permission bit and quarantines one that does not parse. The writer used to
|
||||
* treat both as "start fresh", so one Settings click replaced a hand-edited file with a
|
||||
* one-key file, or rewrote a refused file as 0600 and so trusted it. It now starts fresh
|
||||
* ONLY on ENOENT and otherwise throws `RegistryWriteRefusedError`, leaving the file alone.
|
||||
* - **Unique temp file.** Every write gets its own tmp name before the rename, so two writes
|
||||
* can never rename each other's temp file away (the ENOENT-on-rename 500s).
|
||||
*
|
||||
* Same tmp+rename+0600 shape as `custom-model-hosts.ts`. The file is hand-editable, so a
|
||||
* write must never leave it half-written, and 0600 is the mode `isUnsafePermissions()`
|
||||
* requires on the next read.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { isUnsafePermissions, registryFilePath, reloadCliRegistry } from './registry.js';
|
||||
import type { CliRegistryFile } from './types.js';
|
||||
|
||||
/** A write refused because the existing `clis.json` must not be overwritten. The message is user-facing. */
|
||||
export class RegistryWriteRefusedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RegistryWriteRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the raw override file for mutation. Only a MISSING file starts fresh. A file with
|
||||
* unsafe permissions, one that cannot be read, or one that does not parse is refused rather
|
||||
* than overwritten, because the user's hand-edit is worth more than one toggle.
|
||||
*/
|
||||
export async function readRegistryFileForWrite(): Promise<CliRegistryFile> {
|
||||
const path = registryFilePath();
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { schemaVersion: 1, clis: {} };
|
||||
throw new RegistryWriteRefusedError(`Cannot read ${path} (${(err as Error).message}); not changing it.`);
|
||||
}
|
||||
if (isUnsafePermissions(path)) {
|
||||
throw new RegistryWriteRefusedError(
|
||||
`${path} has group/world permission bits, so Codeman ignores it. Run \`chmod 600 ${path}\` and check its contents before changing CLIs here.`
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new RegistryWriteRefusedError(
|
||||
`${path} is not valid JSON (${(err as Error).message}). Fix or remove it before changing CLIs here.`
|
||||
);
|
||||
}
|
||||
const clis = (parsed as { clis?: unknown } | null)?.clis;
|
||||
if (typeof parsed !== 'object' || parsed === null || typeof clis !== 'object' || clis === null) {
|
||||
throw new RegistryWriteRefusedError(`${path} has no "clis" object. Fix or remove it before changing CLIs here.`);
|
||||
}
|
||||
return parsed as CliRegistryFile;
|
||||
}
|
||||
|
||||
export async function writeRegistryFile(file: CliRegistryFile): Promise<void> {
|
||||
const target = registryFilePath();
|
||||
const dir = dirname(target);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(file, null, 2), { mode: 0o600 });
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.rm(tmp, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let mutationChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Run one registry mutation. The chain holds exactly one at a time: `fn` receives the
|
||||
* current file and returns `{ file, result }`. If `file` is set it is written and the
|
||||
* registry reloaded before the next mutation starts; if not, nothing is written, which is
|
||||
* how a validation failure returns early. Checks made inside `fn` (does this id exist,
|
||||
* is it a duplicate) therefore see every earlier mutation's result.
|
||||
*
|
||||
* A failed mutation rejects its own caller only. The chain keeps going.
|
||||
*/
|
||||
export function mutateRegistryFile<T>(
|
||||
fn: (file: CliRegistryFile) => Promise<{ file?: CliRegistryFile; result: T }> | { file?: CliRegistryFile; result: T }
|
||||
): Promise<T> {
|
||||
const run = mutationChain.then(async () => {
|
||||
const current = await readRegistryFileForWrite();
|
||||
const { file, result } = await fn(current);
|
||||
if (file) {
|
||||
await writeRegistryFile(file);
|
||||
reloadCliRegistry();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
mutationChain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
@@ -48,6 +48,16 @@ function filePath(): string {
|
||||
return dataPath('clis.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved path of `~/.codeman/clis.json`, exported for the write API
|
||||
* (`cli-registry-writer.ts`, docs/cli-enable-disable-plan.md Phases 3/5) so both the read and
|
||||
* write sides resolve the SAME path through the SAME instance-scoped helper — never a second
|
||||
* `dataPath('clis.json')` call that could drift from this one under a future `dataPath()` change.
|
||||
*/
|
||||
export function registryFilePath(): string {
|
||||
return filePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that must never be merged out of a hand-editable JSON file.
|
||||
*
|
||||
@@ -90,8 +100,11 @@ export interface LoadResult {
|
||||
* as mode 0o666 there regardless of its actual ACL), so this check would flag every file on
|
||||
* Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which
|
||||
* this check cannot see and does not attempt to.
|
||||
*
|
||||
* Exported for `registry-writer.ts`, which must refuse the same files: rewriting a refused
|
||||
* file as 0600 would silently turn it into trusted config.
|
||||
*/
|
||||
function isUnsafePermissions(path: string): boolean {
|
||||
export function isUnsafePermissions(path: string): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
try {
|
||||
const mode = statSync(path).mode & 0o777;
|
||||
|
||||
@@ -338,6 +338,15 @@ const capabilitiesSchema = z
|
||||
// Bounded hard: this is how far up the screen a config file may push the search,
|
||||
// and every row it adds is one more row the agent itself may be able to write.
|
||||
watchingLines: z.number().int().min(1).max(8).optional(),
|
||||
// Same guard again: tested against a pane row every time a session settles.
|
||||
awaitingLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
|
||||
|
||||
@@ -90,7 +90,7 @@ function agentDefaults(): Pick<
|
||||
// `accent` still has no reader, so nothing rendered changes because of it.
|
||||
const CLAUDE: CliEntry = {
|
||||
id: 'claude' as CliEntry['id'],
|
||||
label: 'Claude',
|
||||
label: 'Claude Code',
|
||||
shortBadge: 'CC',
|
||||
accent: '#3b82f6',
|
||||
enabled: true,
|
||||
@@ -237,7 +237,24 @@ const CLAUDE: CliEntry = {
|
||||
// carry a count. A footer that ever drew the chip as its only item would report no
|
||||
// watching rather than open that door. See `watchingLabel()` in
|
||||
// `session-activity.ts`.
|
||||
watchingLine: String.raw`·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`,
|
||||
// ⚠️ An Artifact comment monitor is the one chip that waits on the user. The agent
|
||||
// has published a page and hears nothing until somebody comments on it, so the
|
||||
// lookahead refuses the whole row while that chip is on it, whatever else is
|
||||
// running beside it. The `^` is what makes the lookahead judge the row once:
|
||||
// without it the engine retries from each later position, and a start past the
|
||||
// chip reports the shell beside it. The lookahead keys on "Artifact" alone, so a
|
||||
// footer cut off mid-chip (`· 1 Artifact…`, `· 1 Artifact comm…`) is still refused;
|
||||
// no other chip on this row says "Artifact". Counting the chip as watching kept the
|
||||
// idle alert quiet for a session that was waiting for a human.
|
||||
watchingLine: String.raw`^(?!.*Artifact).*?·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?))`,
|
||||
// When a turn ends while background agents or an ultracode workflow are still
|
||||
// running, Claude swaps its `✻ Brewed for 1m 18s` closing row for
|
||||
// `✻ Waiting for 2 background agents and 1 dynamic workflow to finish` and resumes
|
||||
// by itself when they report back. Read from the 2.1.283 bundle (the turn-duration
|
||||
// renderer) and a live pane on 2026-09-28. The row is a snapshot taken at turn end
|
||||
// and never redrawn, which is why only the newest row above the composer counts.
|
||||
// Anchored on column 0: Claude's own rows start there, the agent's prose never does.
|
||||
awaitingLine: String.raw`^✻ Waiting for \d+ (?:background agents?|dynamic workflows?)\b`,
|
||||
},
|
||||
requiresMux: false,
|
||||
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
||||
@@ -246,6 +263,8 @@ const CLAUDE: CliEntry = {
|
||||
transcript: 'claude-jsonl',
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } },
|
||||
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
|
||||
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
|
||||
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
|
||||
keyboardAccessory: 'agent',
|
||||
privilegedCommandGate: false,
|
||||
|
||||
@@ -362,6 +362,19 @@ export interface CliCapabilities {
|
||||
* alert. See `watchingLabel()` in `session-activity.ts`.
|
||||
*/
|
||||
watchingLines?: number;
|
||||
/**
|
||||
* Source of a regex matching the row this CLI closes a turn with when it ended that
|
||||
* turn to WAIT for workers it started and will resume on its own once they finish,
|
||||
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`. A pane showing it counts
|
||||
* as working, not idle: nothing is being asked of the user, and the next turn starts
|
||||
* without them.
|
||||
*
|
||||
* Unlike `workingLine` this is never searched across the pane. The CLI prints the row
|
||||
* once and never updates it, so the copy from an earlier turn is still on screen after
|
||||
* the workers are done. Only the newest transcript row directly above the composer is
|
||||
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
|
||||
*/
|
||||
awaitingLine?: string;
|
||||
};
|
||||
/**
|
||||
* How many columns this CLI indents its transcript body by, so a copy taken from its
|
||||
@@ -670,12 +683,14 @@ export interface CliOverlays {
|
||||
/**
|
||||
* ⚠️ DECLARED-FOR-LATER: fields no code reads yet.
|
||||
*
|
||||
* `shortBadge`, `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
|
||||
* `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
|
||||
* `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND
|
||||
* behaviour, and the frontend is deliberately untouched by the change that introduced this
|
||||
* registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
|
||||
* behaviour, and most of the frontend is deliberately untouched by the change that introduced
|
||||
* this registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
|
||||
* hand-authored per-CLI rules, and moving them is its own piece of work with its own way of
|
||||
* being verified (a mobile/browser suite the CI gate cannot see).
|
||||
* being verified (a mobile/browser suite the CI gate cannot see). `shortBadge` graduated out of
|
||||
* this list (docs/cli-enable-disable-plan.md, Phase 2): `GET /api/clis` reads it for the
|
||||
* CLI-management Settings list.
|
||||
*
|
||||
* They are declared now because each entry should describe its CLI completely, and because
|
||||
* transcribing them while the hand-written source is still on screen is when the values are
|
||||
|
||||
@@ -69,7 +69,9 @@ const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
|
||||
* shown to the user and claude's does not follow the pattern.
|
||||
*/
|
||||
const DOCTOR_ROW_OVERRIDES: Record<string, { id?: string; label?: string; usedBy: string[] }> = {
|
||||
claude: { usedBy: ['Claude Code sessions (default backend)'] },
|
||||
// The label override keeps the doctor row's historical "Claude CLI" spelling now that
|
||||
// the registry label is the product name, "Claude Code".
|
||||
claude: { label: 'Claude CLI', usedBy: ['Claude Code sessions (default backend)'] },
|
||||
opencode: { usedBy: ['OpenCode sessions'] },
|
||||
codex: { usedBy: ['Codex sessions'] },
|
||||
gemini: { usedBy: ['Gemini sessions'] },
|
||||
|
||||
@@ -99,3 +99,11 @@ export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
|
||||
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Gap between a paste-mode cron prompt's text and its Enter (ms). The two must be
|
||||
* separate writes: Claude Code takes a raw `<text>\r` burst of about a hundred
|
||||
* characters as a paste and turns its `\r` into a newline. A separate `\r` 80 ms
|
||||
* after the text was measured to submit; this leaves room for a longer prompt.
|
||||
*/
|
||||
export const CRON_PASTE_ENTER_DELAY_MS = 300;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
|
||||
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
|
||||
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
||||
import { CRON_PASTE_ENTER_DELAY_MS, CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
||||
import {
|
||||
DEFAULT_BLOCKED_TREES,
|
||||
isBlockedAttachmentPath,
|
||||
@@ -100,6 +100,41 @@ const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TR
|
||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
||||
const HAS_NEWLINE = /[\r\n]/;
|
||||
|
||||
/** The three session calls prompt delivery needs, so it can be tested without a PTY. */
|
||||
type CronPromptTarget = Pick<Session, 'write' | 'writeViaMux' | 'verifySubmitted'>;
|
||||
|
||||
/**
|
||||
* Send a cron job's (single-line) prompt into its session and press Enter.
|
||||
*
|
||||
* `typed` goes through the mux: the text is typed, Enter is its own key, and the
|
||||
* session re-presses it while the prompt is still on the composer.
|
||||
*
|
||||
* `paste` writes the text straight into the PTY, and must send its Enter as a
|
||||
* SEPARATE write. It used to send `<text>\r` in one piece, and Claude Code (measured
|
||||
* on 2.1.283) takes a burst of about a hundred characters as a paste, so the `\r`
|
||||
* landed as a newline and the prompt sat unsent while the run reported
|
||||
* `prompt_sent`. The Enter goes down the same PTY as the text, so it cannot overtake
|
||||
* it, and the same composer check then covers a CLI that was not taking Enter yet.
|
||||
*
|
||||
* @returns false when the session had no PTY or mux to write to
|
||||
*/
|
||||
export async function deliverCronPrompt(
|
||||
target: CronPromptTarget,
|
||||
prompt: string,
|
||||
inputMode: CronJob['inputMode'],
|
||||
wait: (ms: number) => Promise<void> = delay
|
||||
): Promise<boolean> {
|
||||
if (inputMode !== 'paste') {
|
||||
return target.writeViaMux(prompt.endsWith('\r') ? prompt : `${prompt}\r`);
|
||||
}
|
||||
const text = prompt.replace(/[\r\n]+$/, '');
|
||||
if (!target.write(text)) return false;
|
||||
await wait(CRON_PASTE_ENTER_DELAY_MS);
|
||||
if (!target.write('\r')) return false;
|
||||
target.verifySubmitted(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Order-insensitive equality for the weekly-days arrays. */
|
||||
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
|
||||
const x = [...(a ?? [])].sort((p, q) => p - q);
|
||||
@@ -623,15 +658,9 @@ export class CronService {
|
||||
const s = this.deps.sessions.get(sessionId);
|
||||
if (!s) return;
|
||||
try {
|
||||
const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`;
|
||||
let delivered = true;
|
||||
if (job.inputMode === 'paste') {
|
||||
s.write(payload);
|
||||
} else {
|
||||
delivered = await s.writeViaMux(payload);
|
||||
}
|
||||
const delivered = await deliverCronPrompt(s, prompt, job.inputMode);
|
||||
if (!delivered) {
|
||||
this.failRun(job, run, 'Failed to send prompt: mux write failed');
|
||||
this.failRun(job, run, 'Failed to send prompt: the session could not be written to');
|
||||
return;
|
||||
}
|
||||
run.status = 'prompt_sent';
|
||||
|
||||
+31
-2
@@ -615,6 +615,15 @@ export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray<readonly [string, string]> =
|
||||
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Environment variable → Dockerfile ARG for the image's system Git identity.
|
||||
* ⚠️ Mirrors `GIT_IDENTITY_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
|
||||
*/
|
||||
export const GIT_IDENTITY_BUILD_ARGS: ReadonlyArray<readonly [string, string]> = [
|
||||
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
|
||||
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
|
||||
];
|
||||
|
||||
/**
|
||||
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
||||
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
||||
@@ -633,9 +642,29 @@ export function gitHostCliBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string,
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--build-arg` pairs for a configured Git identity. An absent pair leaves
|
||||
* Git unconfigured, preserving existing deployments; a partial pair is refused.
|
||||
* ⚠️ Mirrors `gitIdentityBuildArgPairs()` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
|
||||
*/
|
||||
export function gitIdentityBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> {
|
||||
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? ''] as [string, string]);
|
||||
const configured = pairs.filter(([, value]) => value !== '');
|
||||
if (configured.length === 0) return [];
|
||||
if (configured.length !== pairs.length) {
|
||||
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
|
||||
throw new Error(`${names} must both be set when configuring Git identity`);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
||||
export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> {
|
||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')], ...gitHostCliBuildArgPairs(env)];
|
||||
return [
|
||||
['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')],
|
||||
...gitHostCliBuildArgPairs(env),
|
||||
...gitIdentityBuildArgPairs(env),
|
||||
];
|
||||
}
|
||||
|
||||
// ========== Credential mount resolution (IO) ==========
|
||||
@@ -1204,7 +1233,7 @@ function buildAgentImage(
|
||||
try {
|
||||
buildArgPairs = agentImageBuildArgPairs();
|
||||
} catch (err) {
|
||||
// A malformed CODEMAN_AGENT_IMAGE_INSTALL_* value: report it like any other build failure.
|
||||
// A malformed CODEMAN_AGENT_IMAGE_* value: report it like any other build failure.
|
||||
return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) });
|
||||
}
|
||||
const argv = dockerEngineArgv(docker);
|
||||
|
||||
+23
-1
@@ -90,6 +90,13 @@ export interface CreateSessionOptions {
|
||||
workingDir: string;
|
||||
mode: SessionMode;
|
||||
name?: string;
|
||||
/**
|
||||
* Name pinned on a claude spawn as `--name` (version-gated, sanitized, local only).
|
||||
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
|
||||
* entry and the terminal title, and a pinned title stops Claude generating its own,
|
||||
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
|
||||
*/
|
||||
cliName?: string;
|
||||
niceConfig?: NiceConfig;
|
||||
model?: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
@@ -123,8 +130,15 @@ export interface RespawnPaneOptions {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
mode: SessionMode;
|
||||
/** Session display name; a respawned claude keeps its `--name` peer name (version-gated, local only). */
|
||||
/** Session display name (tab name). */
|
||||
name?: string;
|
||||
/**
|
||||
* Name pinned on a respawned claude as `--name` (version-gated, sanitized, local only).
|
||||
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
|
||||
* entry and the terminal title, and a pinned title stops Claude generating its own,
|
||||
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
|
||||
*/
|
||||
cliName?: string;
|
||||
niceConfig?: NiceConfig;
|
||||
model?: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
@@ -322,6 +336,14 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
*/
|
||||
getPaneExit?(muxName: string): PaneExit | undefined;
|
||||
|
||||
/**
|
||||
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
|
||||
* reports, or 0 when it reports none. The exited-agent sweep closes a session
|
||||
* only once this reaches `CLEAN_EXIT_CONFIRMING_READS` (`pane-exit-sweep.ts`),
|
||||
* and a multiplexer without this method never has a session closed by it.
|
||||
*/
|
||||
getPaneExitReadCount?(muxName: string): number;
|
||||
|
||||
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
|
||||
clearPaneExit?(muxName: string): void;
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @fileoverview The exited-agent sweep's decision rule (Ark0N/Codeman#446).
|
||||
*
|
||||
* Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the
|
||||
* CLI while the pane, the tmux session and the `tmux attach-session` process
|
||||
* all live on. Part 1 of #446 records that as `SessionState.paneExit`. This
|
||||
* module decides when such a session is closed, the way the X button closes
|
||||
* it, so finished sessions stop piling up on the board.
|
||||
*
|
||||
* The rule closes a session only on a POSITIVE observation of a clean exit:
|
||||
*
|
||||
* - The exit status must be an explicit numeric 0 with no signal. An absent
|
||||
* status is UNKNOWN, never 0: on tmux 3.2a a SIGKILLed pane reports neither a
|
||||
* status nor a signal, so reading absence as clean would sweep an agent the
|
||||
* OOM killer took. A non-zero status or any signal keeps the row, marked with
|
||||
* the exit, as the crash evidence #210 was filed to keep.
|
||||
* - At least {@link CLEAN_EXIT_CONFIRMING_READS} authoritative pane reads must
|
||||
* have agreed on that exit. A failed, empty or skipped read counts for
|
||||
* nothing, because unknown never closes anything.
|
||||
* - No start, attach or relaunch may be in flight for the session. The
|
||||
* dead-pane branch of `Session._setupOrAttachMuxSession()` respawns an exited
|
||||
* pane on purpose, and for a few seconds that pane still reads as dead.
|
||||
* - The exit must land at least {@link CLEAN_EXIT_MIN_PANE_LIFETIME_MS} after
|
||||
* the last start, attach or relaunch finished. A CLI that prints a startup
|
||||
* error ("not logged in", a bad profile, a config error) and exits 0 would
|
||||
* otherwise lose its tab, and the error with it, seconds after launch. Its
|
||||
* row stays, marked `exited (0)`, for the user to read and close.
|
||||
*
|
||||
* Scoping to local mux-backed sessions happens before this rule runs:
|
||||
* `Session.setPaneExit()` forces the field to UNKNOWN for direct-PTY, remote,
|
||||
* docker and discovered sessions, so their `paneExit` never reaches here.
|
||||
*
|
||||
* Pure, so the rule is unit-tested without a server (test/pane-exit-sweep.test.ts).
|
||||
*/
|
||||
import type { PaneExit } from './types/index.js';
|
||||
|
||||
/**
|
||||
* How many authoritative pane reads must agree on a clean exit before the
|
||||
* session is closed. At the watcher's 2 s cadence two reads mean a finished
|
||||
* session disappears within about four seconds of its agent exiting.
|
||||
*/
|
||||
export const CLEAN_EXIT_CONFIRMING_READS = 2;
|
||||
|
||||
/**
|
||||
* How long a pane must have been up before a clean exit closes its session.
|
||||
* An exit sooner than this after the last pane start is read as a startup
|
||||
* failure rather than a user ending the agent, and the row is kept.
|
||||
*/
|
||||
export const CLEAN_EXIT_MIN_PANE_LIFETIME_MS = 10_000;
|
||||
|
||||
/** The lifecycle-log reason recorded when the sweep closes a session. */
|
||||
export const CLEAN_EXIT_CLOSE_REASON = 'agent exited cleanly (status 0)';
|
||||
|
||||
/**
|
||||
* Is this exit a clean one? True only for an explicit numeric status of 0 with
|
||||
* no signal reported.
|
||||
*
|
||||
* ⚠ Never widen this to `(exit.status ?? 0) === 0` or to "no signal, so it was
|
||||
* clean". An absent status is how a signal death presents on tmux 3.2a, and
|
||||
* that shortcut would close crashed agents with nothing failing to warn you.
|
||||
*/
|
||||
export function isCleanPaneExit(exit: PaneExit | undefined): boolean {
|
||||
if (!exit) return false;
|
||||
if (exit.signal !== undefined) return false;
|
||||
return exit.status === 0;
|
||||
}
|
||||
|
||||
/** Everything the sweep needs to know about one session. */
|
||||
export interface CleanExitSweepCandidate {
|
||||
/** The session's published exit, already scoped by `Session.setPaneExit()`. */
|
||||
paneExit: PaneExit | undefined;
|
||||
/** Authoritative pane reads that agreed on that exit (`getPaneExitReadCount()`). */
|
||||
confirmingReads: number;
|
||||
/** A start, attach or relaunch is running for this session's pane. */
|
||||
paneLifecycleInFlight: boolean;
|
||||
/** The session is already being closed or detached. */
|
||||
closing: boolean;
|
||||
/**
|
||||
* When the last start, attach or relaunch of this pane finished
|
||||
* (`Session.paneStartedAt`), or 0 when none has run in this process.
|
||||
*/
|
||||
paneStartedAt: number;
|
||||
}
|
||||
|
||||
/** Should the sweep close this session now? See the file overview for the rule. */
|
||||
export function shouldCloseCleanlyExitedSession(candidate: CleanExitSweepCandidate): boolean {
|
||||
if (candidate.closing) return false;
|
||||
if (candidate.paneLifecycleInFlight) return false;
|
||||
if (!isCleanPaneExit(candidate.paneExit)) return false;
|
||||
// `at` is when this server first read the pane dead, so an exit during the
|
||||
// start itself lands BEFORE `paneStartedAt` and is kept too.
|
||||
if (
|
||||
candidate.paneStartedAt > 0 &&
|
||||
candidate.paneExit!.at - candidate.paneStartedAt < CLEAN_EXIT_MIN_PANE_LIFETIME_MS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return candidate.confirmingReads >= CLEAN_EXIT_CONFIRMING_READS;
|
||||
}
|
||||
+27
-14
@@ -19,19 +19,22 @@
|
||||
* touching its status, so a pinned session a reboot killed still reads `idle` or
|
||||
* `busy` and stays eligible.
|
||||
*
|
||||
* ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT
|
||||
* recognise today, and a reboot restores it. `/exit` ends the CLI inside the
|
||||
* pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the
|
||||
* `tmux attach-session` process, which stays alive throughout — so no exit
|
||||
* handler runs, no lifecycle `exit` is logged, and the record keeps both its pid
|
||||
* and `status: 'idle'`. Nothing durable distinguishes it from a session that was
|
||||
* simply idle when the power went. Ark0N/Codeman#446 covers making Codeman
|
||||
* notice the dead pane; until a record can say the agent is gone, this pass will
|
||||
* offer those sessions back, and the user dismisses or closes them.
|
||||
* ⚠️ Ending the AGENT rather than the session leaves no trace in `status` or
|
||||
* `pid`. `/exit` ends the CLI inside the pane, `remain-on-exit` keeps the pane,
|
||||
* and the PTY Codeman owns is the `tmux attach-session` process, which stays
|
||||
* alive throughout — so no exit handler runs, no lifecycle `exit` is logged,
|
||||
* and the record keeps both its pid and `status: 'idle'`. Ark0N/Codeman#446
|
||||
* handles it in two steps. The pane-exit watcher persists `paneExit`, and the
|
||||
* clean-exit sweep (`pane-exit-sweep.ts`) closes a session whose agent exited
|
||||
* with status 0 through `cleanupSession()`, which leaves the durable record
|
||||
* described above. This module also refuses a record whose persisted
|
||||
* `paneExit` is a clean exit, which covers a session that exited moments
|
||||
* before the power went, before the sweep reached it. A crashed agent's record
|
||||
* stays eligible, like the row the sweep leaves on the board for it.
|
||||
*
|
||||
* The `pid` check below is therefore NOT that rule. It refuses a record whose
|
||||
* attach process was already gone, which is a session that never started or
|
||||
* whose pane died outright.
|
||||
* The `pid` check below is NOT that rule. It refuses a record whose attach
|
||||
* process was already gone, which is a session that never started or whose
|
||||
* pane died outright.
|
||||
*
|
||||
* @dependencies types (SessionState), config/cli-registry
|
||||
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
|
||||
@@ -41,6 +44,7 @@
|
||||
|
||||
import type { SessionState } from './types.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { isCleanPaneExit } from './pane-exit-sweep.js';
|
||||
|
||||
/** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
|
||||
const RESTORABLE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy', 'error']);
|
||||
@@ -109,7 +113,7 @@ export function resolveResumeConversationId(state: SessionState): string {
|
||||
/**
|
||||
* Why one session was passed over. Reported for logging and shown to the user.
|
||||
*
|
||||
* The first seven are decided before anything is built. `capacity-reached` and
|
||||
* All but the last two are decided before anything is built. `capacity-reached` and
|
||||
* `rebuild-failed` can only happen once a click is spending the plan, and they
|
||||
* are the two the banner must not confuse with a missing workspace: one means
|
||||
* "try again after closing something", the other means the CLI would not start.
|
||||
@@ -120,6 +124,7 @@ export interface RebootRestoreRejection {
|
||||
| 'no-persisted-record'
|
||||
| 'intentionally-ended'
|
||||
| 'not-running'
|
||||
| 'agent-exited'
|
||||
| 'respawn-blocked'
|
||||
| 'remote-or-docker'
|
||||
| 'unsupported-mode'
|
||||
@@ -191,7 +196,8 @@ export function planRebootRestore(
|
||||
//
|
||||
// ⚠️ This does NOT catch a session the user ended with `/exit`. See the
|
||||
// module header: that leaves the pid in place, because the pid is the tmux
|
||||
// attach process and `remain-on-exit` keeps it alive.
|
||||
// attach process and `remain-on-exit` keeps it alive. The `paneExit` check
|
||||
// below catches it instead.
|
||||
//
|
||||
// Conservative on purpose. A session that somehow persisted no pid while
|
||||
// genuinely running is not offered, and its conversation stays reachable
|
||||
@@ -200,6 +206,13 @@ export function planRebootRestore(
|
||||
skipped.push({ sessionId, reason: 'not-running' });
|
||||
continue;
|
||||
}
|
||||
if (isCleanPaneExit(state.paneExit)) {
|
||||
// The user ended the agent, and the clean-exit sweep would have closed the
|
||||
// session had the power not gone first (Ark0N/Codeman#446). The same
|
||||
// explicit-0 rule applies: an absent status is unknown, not clean.
|
||||
skipped.push({ sessionId, reason: 'agent-exited' });
|
||||
continue;
|
||||
}
|
||||
if (state.respawnBlocked === true) {
|
||||
// The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
|
||||
skipped.push({ sessionId, reason: 'respawn-blocked' });
|
||||
|
||||
@@ -158,3 +158,59 @@ export function watchingLabel(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many rows above the composer the turn's closing row may sit. Between the two Claude
|
||||
* draws only its composer border and, sometimes, a right-aligned hint
|
||||
* (`new task? /clear to save 169.1k tokens`), so this leaves room for a blank row or two
|
||||
* and no more. A bound, not a tuning knob: the walk must never reach far enough up the
|
||||
* transcript to find an old turn's row.
|
||||
*/
|
||||
export const AWAITING_SEARCH_ROWS = 6;
|
||||
|
||||
/** A row that opens with a box-drawing character is the composer's frame, not transcript. */
|
||||
const COMPOSER_FRAME_ROW = /^[─-╿]/;
|
||||
|
||||
/**
|
||||
* Whether the pane's newest turn ended by handing off to workers the CLI will wait for,
|
||||
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`.
|
||||
*
|
||||
* Such a pane is quiet and shows its composer, so every other signal calls it idle, yet
|
||||
* nothing is being asked of the user: the CLI resumes by itself when the workers report
|
||||
* back. That is why a session in this state counts as working.
|
||||
*
|
||||
* ⚠️ The row is a snapshot. Claude renders it once, at the end of the turn, and never
|
||||
* updates it, so after the workers finish the same words are still on screen above the
|
||||
* follow-up turn. Matching them anywhere on the pane would pin the session busy for as
|
||||
* long as they stay visible. Only the newest transcript row counts: the walk starts at
|
||||
* the composer (the LAST row carrying `promptGlyph`), steps up past blank rows, the
|
||||
* composer's frame and anything indented (a right-aligned hint, a wrapped continuation),
|
||||
* and tests the first row that starts in column 0. A follow-up turn always puts rows of
|
||||
* its own there, so the stale copy is never the one tested.
|
||||
*
|
||||
* @param promptGlyph the CLI's composer glyph (`capabilities.workDetect.promptGlyph`)
|
||||
* @returns false when the screen shows no composer, which is no evidence either way
|
||||
*/
|
||||
export function isAwaitingWorkers(paneText: string | null | undefined, pattern: RegExp, promptGlyph: string): boolean {
|
||||
if (!paneText) return false;
|
||||
const rows = stripAnsi(paneText)
|
||||
.split('\n')
|
||||
.map((row) => row.trimEnd());
|
||||
let composer = -1;
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
// Claude has drawn its composer both bare (`❯ …` between rules) and boxed (`│ ❯ … │`).
|
||||
if (rows[i].replace(/^[\s│]+/, '').startsWith(promptGlyph)) {
|
||||
composer = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (composer < 0) return false;
|
||||
for (let i = composer - 1; i >= Math.max(0, composer - AWAITING_SEARCH_ROWS); i--) {
|
||||
const row = rows[i];
|
||||
if (row === '' || /^\s/.test(row) || COMPOSER_FRAME_ROW.test(row)) continue;
|
||||
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
|
||||
pattern.lastIndex = 0;
|
||||
return pattern.test(row);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+124
-2
@@ -85,6 +85,7 @@ import {
|
||||
isSustainedActivity,
|
||||
isPaneQuiet,
|
||||
watchingLabel,
|
||||
isAwaitingWorkers,
|
||||
WATCHING_TAIL_LINES,
|
||||
IDLE_RECHECK_MS,
|
||||
PANE_PROBE_MIN_INTERVAL_MS,
|
||||
@@ -531,6 +532,8 @@ export class Session extends EventEmitter {
|
||||
private _watchingLineRe: RegExp | null | undefined = undefined;
|
||||
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
|
||||
private _watchingWindow = WATCHING_TAIL_LINES;
|
||||
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
|
||||
private _awaitingLineRe: RegExp | null | undefined = undefined;
|
||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||
@@ -582,6 +585,21 @@ export class Session extends EventEmitter {
|
||||
* rendered as "alive".
|
||||
*/
|
||||
private _paneExit: PaneExit | null = null;
|
||||
/**
|
||||
* How many starts, attaches or relaunches are running for this session's
|
||||
* pane. While one is, a dead-pane reading may describe a pane that is being
|
||||
* revived on purpose, so the exited-agent sweep leaves the session alone
|
||||
* (Ark0N/Codeman#446). A counter rather than a flag, so two overlapping
|
||||
* operations cannot clear each other's mark.
|
||||
*/
|
||||
private _paneLifecycleOps = 0;
|
||||
/** When the last pane start, attach or relaunch finished (ms), 0 when none has run. */
|
||||
private _paneStartedAt = 0;
|
||||
/**
|
||||
* The server has started closing this session, so no start or attach may
|
||||
* begin (see {@link markClosing}).
|
||||
*/
|
||||
private _closing = false;
|
||||
/**
|
||||
* This session was rebuilt from the tmux socket rather than from Codeman's
|
||||
* own records, so its `remote`/`docker` metadata is missing rather than known
|
||||
@@ -1183,6 +1201,49 @@ export class Session extends EventEmitter {
|
||||
return this._paneExit ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a start, attach or relaunch is running for this session's pane.
|
||||
* The exited-agent sweep reads it (see `pane-exit-sweep.ts`): the dead-pane
|
||||
* branch of {@link _setupOrAttachMuxSession} respawns an exited pane, and
|
||||
* until it finishes and clears the exit, the pane still reads as dead.
|
||||
*/
|
||||
get paneLifecycleInFlight(): boolean {
|
||||
return this._paneLifecycleOps > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the last start, attach or relaunch of this pane finished, or 0 when
|
||||
* none has run in this process. The exited-agent sweep keeps an exit that
|
||||
* lands within `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` of it, since that reads as a
|
||||
* CLI failing at startup rather than a user ending it. An attach to a pane
|
||||
* that was already running stamps it too, which only costs a user who
|
||||
* `/exit`s within seconds of a server restart a row to close by hand.
|
||||
*/
|
||||
get paneStartedAt(): number {
|
||||
return this._paneStartedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this session as being closed, or clear the mark after a close that
|
||||
* failed. While it is set, {@link startInteractive} and {@link startShell}
|
||||
* refuse to run. A start that raced a close would otherwise launch a CLI in a
|
||||
* tmux session whose record is about to be deleted (Ark0N/Codeman#446).
|
||||
*/
|
||||
markClosing(closing: boolean): void {
|
||||
this._closing = closing;
|
||||
}
|
||||
|
||||
/** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */
|
||||
private async _withPaneLifecycle<T>(op: () => Promise<T>): Promise<T> {
|
||||
this._paneLifecycleOps++;
|
||||
try {
|
||||
return await op();
|
||||
} finally {
|
||||
this._paneLifecycleOps--;
|
||||
this._paneStartedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget this pane's exit, on both this record and the mux layer's cache.
|
||||
* Every path that starts or relaunches a command in the pane calls it, and
|
||||
@@ -1560,6 +1621,19 @@ export class Session extends EventEmitter {
|
||||
return this._nameSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to pin on the Claude CLI as `--name`, or undefined to let Claude
|
||||
* title the conversation itself. `--name` is the prompt-box label, the
|
||||
* `/resume` picker entry and the terminal title all at once, and a pinned
|
||||
* title stops Claude generating its own, so only a name the user chose is
|
||||
* worth pinning. Pinning the `w1-myapp` placeholder gave every conversation
|
||||
* in a case the same `/resume` entry; an auto name is a cut of the first
|
||||
* prompt, which Claude's own generated title already beats.
|
||||
*/
|
||||
get cliPinnedName(): string | undefined {
|
||||
return this._nameSource === 'manual' ? this._name : undefined;
|
||||
}
|
||||
|
||||
setAutoClear(enabled: boolean, threshold?: number): void {
|
||||
this._autoOps.setAutoClear(enabled, threshold);
|
||||
}
|
||||
@@ -1875,6 +1949,14 @@ export class Session extends EventEmitter {
|
||||
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
||||
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
||||
spawnErrLabel: string;
|
||||
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
|
||||
return this._withPaneLifecycle(() => this._doSetupOrAttachMuxSession(options));
|
||||
}
|
||||
|
||||
private async _doSetupOrAttachMuxSession(options: {
|
||||
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
||||
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
||||
spawnErrLabel: string;
|
||||
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
|
||||
const mux = this._mux!;
|
||||
|
||||
@@ -2058,6 +2140,10 @@ export class Session extends EventEmitter {
|
||||
* the mux session is gone — see {@link reattachRemote} for that reasoning).
|
||||
*/
|
||||
async restartCli(): Promise<boolean> {
|
||||
return this._withPaneLifecycle(() => this._doRestartCli());
|
||||
}
|
||||
|
||||
private async _doRestartCli(): Promise<boolean> {
|
||||
if (!this._useMux || !this._mux || !this._muxSession) return false;
|
||||
const mux = this._mux;
|
||||
|
||||
@@ -2093,6 +2179,7 @@ export class Session extends EventEmitter {
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
name: this._name,
|
||||
cliName: this.cliPinnedName,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
@@ -2445,6 +2532,9 @@ export class Session extends EventEmitter {
|
||||
if (this.ptyProcess) {
|
||||
throw new Error('Session already has a running process');
|
||||
}
|
||||
if (this._closing) {
|
||||
throw new Error('Session is being closed');
|
||||
}
|
||||
|
||||
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
|
||||
// rather than at PTY spawn so a slow mux attach still counts as startup.
|
||||
@@ -2561,6 +2651,7 @@ export class Session extends EventEmitter {
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
name: this._name,
|
||||
cliName: this.cliPinnedName,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
@@ -2699,7 +2790,7 @@ export class Session extends EventEmitter {
|
||||
this._model,
|
||||
this._allowedTools,
|
||||
this._effort,
|
||||
this._name,
|
||||
this.cliPinnedName,
|
||||
getClaudeCliVersion()
|
||||
);
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
@@ -3005,7 +3096,10 @@ export class Session extends EventEmitter {
|
||||
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
|
||||
this._lastPaneProbeAt = now;
|
||||
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
|
||||
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
|
||||
// A turn that ended by handing off to workers the CLI waits for is work too: the
|
||||
// composer is up and the pane is quiet, but the next turn starts without the user.
|
||||
this._lastPaneProbeWorking =
|
||||
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
|
||||
this._readWatching(text);
|
||||
return this._lastPaneProbeWorking;
|
||||
}
|
||||
@@ -3063,6 +3157,21 @@ export class Session extends EventEmitter {
|
||||
return this._watchingLineRe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the newest turn on this screen ended waiting for workers the CLI started
|
||||
* (Claude's `✻ Waiting for 1 dynamic workflow to finish`). False for a CLI whose
|
||||
* registry entry declares no `awaitingLine`. See `isAwaitingWorkers()`.
|
||||
*/
|
||||
private _paneAwaitsWorkers(paneText: string): boolean {
|
||||
if (this._awaitingLineRe === undefined) {
|
||||
const src = getCli(this.mode)?.capabilities.workDetect?.awaitingLine;
|
||||
this._awaitingLineRe = src ? compileVersionRegex(src) : null;
|
||||
}
|
||||
if (!this._awaitingLineRe) return false;
|
||||
const glyph = getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '❯';
|
||||
return isAwaitingWorkers(paneText, this._awaitingLineRe, glyph);
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex matching this CLI's "a turn is running" status line.
|
||||
*
|
||||
@@ -3265,6 +3374,9 @@ export class Session extends EventEmitter {
|
||||
if (this.ptyProcess) {
|
||||
throw new Error('Session already has a running process');
|
||||
}
|
||||
if (this._closing) {
|
||||
throw new Error('Session is being closed');
|
||||
}
|
||||
|
||||
this._resetBuffers();
|
||||
|
||||
@@ -4095,6 +4207,16 @@ export class Session extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the composer check for a prompt that went out some other way than
|
||||
* `writeViaMux`, e.g. cron's paste mode, which writes the body raw and its Enter
|
||||
* separately. `text` is what the composer line starts with while the prompt is still
|
||||
* unsent; the check re-presses Enter only while that holds.
|
||||
*/
|
||||
verifySubmitted(text: string): void {
|
||||
this._verifySubmitted(`${text}\r`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the composer check for a write that carried Enter (session-submit-verifier.ts):
|
||||
* Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints,
|
||||
|
||||
+33
-6
@@ -287,6 +287,19 @@ export interface PaneExitObservation {
|
||||
exit: PaneExit;
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link PaneExitObservation} as the manager stores it, with a count of the
|
||||
* authoritative reads that have seen this same exit. The count is what lets
|
||||
* the exited-agent sweep act only on a death that more than one read agreed on
|
||||
* (`CLEAN_EXIT_CONFIRMING_READS` in `pane-exit-sweep.ts`). A failed or skipped
|
||||
* read never reaches {@link TmuxManager.applyPaneExits}, so it neither raises
|
||||
* the count nor resets it.
|
||||
*/
|
||||
interface TrackedPaneExit extends PaneExitObservation {
|
||||
/** Authoritative reads that saw this exit, counting the first. */
|
||||
reads: number;
|
||||
}
|
||||
|
||||
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
|
||||
function paneField(fields: string[], index: number): number | undefined {
|
||||
const raw = fields[index];
|
||||
@@ -871,7 +884,7 @@ export function buildSpawnCommand(options: {
|
||||
effort?: EffortLevel;
|
||||
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
|
||||
statusLineCommand?: string;
|
||||
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
|
||||
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
|
||||
sessionName?: string;
|
||||
/**
|
||||
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
|
||||
@@ -1672,7 +1685,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* lives on `Session`, because the remote-reconnect watcher above needs the
|
||||
* raw pane reading.
|
||||
*/
|
||||
private paneExits: Map<string, PaneExitObservation> = new Map();
|
||||
private paneExits: Map<string, TrackedPaneExit> = new Map();
|
||||
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
|
||||
private paneExitInterval: NodeJS.Timeout | null = null;
|
||||
/**
|
||||
@@ -2054,6 +2067,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
mode,
|
||||
name,
|
||||
cliName,
|
||||
niceConfig,
|
||||
model,
|
||||
claudeMode,
|
||||
@@ -2157,7 +2171,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
resumeSessionId,
|
||||
effort,
|
||||
statusLineCommand,
|
||||
sessionName: name,
|
||||
sessionName: cliName,
|
||||
});
|
||||
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
@@ -2385,7 +2399,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
remote,
|
||||
docker,
|
||||
name,
|
||||
cliName,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
@@ -2422,7 +2436,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
resumeSessionId,
|
||||
effort,
|
||||
statusLineCommand,
|
||||
sessionName: name,
|
||||
sessionName: cliName,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
@@ -3074,6 +3088,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return this.paneExits.get(muxName)?.exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many authoritative pane reads have agreed on the exit that
|
||||
* {@link getPaneExit} reports, or 0 when it reports none. A new observation
|
||||
* starts at 1, and every later read that sees the same pane with the same
|
||||
* status and signal adds one.
|
||||
*/
|
||||
getPaneExitReadCount(muxName: string): number {
|
||||
return this.paneExits.get(muxName)?.reads ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
|
||||
* `tmux list-panes -a` answers for every session at once, which is why this
|
||||
@@ -3169,6 +3193,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* changed status, a changed signal, or a different pane pid all start a new
|
||||
* observation — the pid is what catches a second command in the same pane
|
||||
* that happened to exit the same way.
|
||||
*
|
||||
* The same rule decides the read count: a repeat of the stored exit adds one,
|
||||
* and anything that starts a new observation starts the count again at 1.
|
||||
*/
|
||||
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
|
||||
for (const muxName of [...this.paneExits.keys()]) {
|
||||
@@ -3181,7 +3208,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
prev.panePid === next.panePid &&
|
||||
prev.exit.status === next.exit.status &&
|
||||
prev.exit.signal === next.exit.signal;
|
||||
this.paneExits.set(muxName, sameExit ? prev : next);
|
||||
this.paneExits.set(muxName, sameExit ? { ...prev, reads: prev.reads + 1 } : { ...next, reads: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -649,12 +649,13 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
|
||||
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read
|
||||
* "no signal was reported" as "the exit must have been clean". On tmux 3.2a
|
||||
* the absent status IS how a signal death presents, so absent-stays-absent is
|
||||
* the only thing keeping a future clean-exit sweep away from crashed agents:
|
||||
* an agent SIGKILLed by the OOM killer would otherwise read as a user typing
|
||||
* `/exit` and be swept. Nothing here fails when somebody adds that `??` — the
|
||||
* types allow it, the label still renders, and the damage shows up only once
|
||||
* the sweep lands. The rule is enforced in `derivePaneExits()`
|
||||
* (`tmux-manager.ts`), which omits the key rather than defaulting it.
|
||||
* the only thing keeping the clean-exit sweep (`pane-exit-sweep.ts`) away
|
||||
* from crashed agents: an agent SIGKILLed by the OOM killer would otherwise
|
||||
* read as a user typing `/exit` and be closed. Nothing here fails when
|
||||
* somebody adds that `??` — the types allow it and the label still renders.
|
||||
* The rule is enforced in `derivePaneExits()` (`tmux-manager.ts`), which omits
|
||||
* the key rather than defaulting it, and again in `isCleanPaneExit()`, which
|
||||
* accepts only an explicit 0.
|
||||
*/
|
||||
export interface PaneExit {
|
||||
/**
|
||||
|
||||
@@ -204,6 +204,28 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-binary invalidation generation, bumped by `invalidateCliExecutableResolvers()`.
|
||||
*
|
||||
* Every resolver instance (each per-CLI module's private one AND the generic registry
|
||||
* resolver in cli-resolver.ts) is built by the factory below and caches in its own
|
||||
* closure, so there is no instance to reach from outside. Keying on the BINARY name is
|
||||
* what lets one call reach all of them: the CLI-management install/update routes know
|
||||
* which binaries just changed, and every resolver knows its own.
|
||||
*/
|
||||
const binaryGenerations = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Forget every cached result — success and negative-cache backoff alike — for these
|
||||
* binaries, so the next `resolve()` re-runs the chain immediately. For an action that
|
||||
* just changed what is on disk (an install) or what a CLI's binary IS (editing a custom
|
||||
* entry): without it a CLI installed from Settings kept reading as missing for up to the
|
||||
* 5-minute backoff, and an edited entry kept launching its old binary until a restart.
|
||||
*/
|
||||
export function invalidateCliExecutableResolvers(binaries: readonly string[]): void {
|
||||
for (const binary of binaries) binaryGenerations.set(binary, (binaryGenerations.get(binary) ?? 0) + 1);
|
||||
}
|
||||
|
||||
export function createCliExecutableResolver<T = undefined>(
|
||||
options: {
|
||||
binary: string;
|
||||
@@ -235,6 +257,8 @@ export function createCliExecutableResolver<T = undefined>(
|
||||
let failures = 0;
|
||||
/** Timestamp of the most recent miss. */
|
||||
let lastFailureAt = 0;
|
||||
/** The invalidation generation the cached state above belongs to. */
|
||||
let generation = binaryGenerations.get(options.binary) ?? 0;
|
||||
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
|
||||
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
|
||||
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
|
||||
@@ -249,6 +273,13 @@ export function createCliExecutableResolver<T = undefined>(
|
||||
|
||||
return {
|
||||
resolve() {
|
||||
const current = binaryGenerations.get(options.binary) ?? 0;
|
||||
if (current !== generation) {
|
||||
generation = current;
|
||||
cached = null;
|
||||
failures = 0;
|
||||
lastFailureAt = 0;
|
||||
}
|
||||
if (cached) return cached;
|
||||
// Negative cache: a miss is remembered and the chain — whose login-shell
|
||||
// tail is a synchronous 5s-bounded spawn — is not re-run until the
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @fileoverview One answer to "is this CLI installed here?", shared by the page render
|
||||
* (`renderIndexHtml` in server.ts, which injects `window.__codemanCliAvailable` and
|
||||
* `window.__codemanCliCatalog`) and `GET /api/clis` (the Settings list's badge).
|
||||
*
|
||||
* The two used to keep their own copies of the per-CLI probe map, so they could drift apart
|
||||
* and the badge could disagree with the Run menu.
|
||||
*
|
||||
* Every probe is a memoized resolver, so this is cheap to call per request. Dynamic imports
|
||||
* keep the nine resolvers out of any module that never asks.
|
||||
*/
|
||||
|
||||
import type { CliEntry } from '../config/cli-registry/types.js';
|
||||
import { isCliAvailable as isRegistryCliAvailable } from './cli-resolver.js';
|
||||
|
||||
/**
|
||||
* The stock CLIs whose own resolver answers availability. It keeps the resolver's specific
|
||||
* semantics (pi/grok/deepseek identity probes). DeepSeek reports RUNNABLE here, not merely
|
||||
* installed: `dsh` is a profile launcher, and a dsh with no pane-capable profile would
|
||||
* offer a Run button that spawns a pane which dies on arrival.
|
||||
*/
|
||||
export async function probeStockCliAvailability(): Promise<Record<string, boolean>> {
|
||||
const [
|
||||
{ isClaudeAvailable },
|
||||
{ isOpenCodeAvailable },
|
||||
{ isCodexAvailable },
|
||||
{ isGeminiAvailable },
|
||||
{ isAntigravityAvailable },
|
||||
{ isPiAvailable },
|
||||
{ isGrokAvailable },
|
||||
{ isDeepSeekRunnable },
|
||||
{ isOmpAvailable },
|
||||
] = await Promise.all([
|
||||
import('./claude-cli-resolver.js'),
|
||||
import('./opencode-cli-resolver.js'),
|
||||
import('./codex-cli-resolver.js'),
|
||||
import('./gemini-cli-resolver.js'),
|
||||
import('./antigravity-cli-resolver.js'),
|
||||
import('./pi-cli-resolver.js'),
|
||||
import('./grok-cli-resolver.js'),
|
||||
import('./deepseek-cli-resolver.js'),
|
||||
import('./omp-cli-resolver.js'),
|
||||
]);
|
||||
return {
|
||||
claude: isClaudeAvailable(),
|
||||
opencode: isOpenCodeAvailable(),
|
||||
codex: isCodexAvailable(),
|
||||
gemini: isGeminiAvailable(),
|
||||
antigravity: isAntigravityAvailable(),
|
||||
pi: isPiAvailable(),
|
||||
grok: isGrokAvailable(),
|
||||
deepseek: isDeepSeekRunnable(),
|
||||
omp: isOmpAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `entry` installed? A shell entry has no binary to probe, since it is the server's own
|
||||
* login shell. A stock entry with a dedicated resolver uses `stockAvailability`. Anything
|
||||
* else, custom entries included, uses the registry's GENERIC resolver. That is the one a
|
||||
* session spawn uses, and it understands the entry's declared binaries and search dirs.
|
||||
*/
|
||||
export function isCliEntryInstalled(entry: CliEntry, stockAvailability: Record<string, boolean>): boolean {
|
||||
if (entry.kind === 'shell') return true;
|
||||
const id = entry.id as string;
|
||||
return Object.prototype.hasOwnProperty.call(stockAvailability, id)
|
||||
? stockAvailability[id]
|
||||
: isRegistryCliAvailable(id);
|
||||
}
|
||||
@@ -12,7 +12,8 @@
|
||||
* image dir.
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { SessionPort } from './ports/index.js';
|
||||
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
@@ -53,6 +54,73 @@ export async function sweepPasteImagesOnce(
|
||||
return { scanned, deleted };
|
||||
}
|
||||
|
||||
/**
|
||||
* The path two sessions must share to share a paste-image dir: the canonical
|
||||
* path when it can be resolved, so a sibling that reaches the same directory
|
||||
* through a symlink matches, and the normalised path otherwise (a directory
|
||||
* that no longer exists has nothing left to protect).
|
||||
*/
|
||||
function canonicalDir(dir: string): string {
|
||||
try {
|
||||
return realpathSync(dir);
|
||||
} catch {
|
||||
return resolve(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
|
||||
export interface PasteImageDirUser {
|
||||
id: string;
|
||||
workingDir: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does another live session still use this working directory's paste-image
|
||||
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
|
||||
* and several sessions routinely share one case directory, so without this
|
||||
* check closing one session deletes the pasted images a sibling in the same
|
||||
* case still refers to.
|
||||
*
|
||||
* Two kinds of sibling count as live:
|
||||
*
|
||||
* - a session in the server's map, unless it is itself being killed;
|
||||
* - a persisted record whose status is not `stopped`. That covers a session
|
||||
* detached with `killMux=false`, which leaves the server's map while its
|
||||
* tmux pane keeps running, and a session whose detach is still in progress.
|
||||
*
|
||||
* A session being KILLED does not count. Without that exemption, killing two
|
||||
* sessions of one case concurrently (a bulk delete, or the exited-agent sweep
|
||||
* closing two panes on one tick) would have each defer to the other, and
|
||||
* neither would remove the dir.
|
||||
*
|
||||
* Erring toward "in use" only costs a missed deletion, which the periodic
|
||||
* sweep above ages out. A pinned record whose tmux session is gone keeps its
|
||||
* status through boot pruning, so it holds the dir this way until unpinned.
|
||||
*/
|
||||
export function pasteImageDirInUseByOtherSession(input: {
|
||||
live: Iterable<PasteImageDirUser>;
|
||||
persisted: Iterable<PasteImageDirUser>;
|
||||
closingId: string;
|
||||
workingDir: string;
|
||||
killing: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
const target = canonicalDir(input.workingDir);
|
||||
const matches = (user: PasteImageDirUser): boolean =>
|
||||
user.id !== input.closingId &&
|
||||
!input.killing.has(user.id) &&
|
||||
!!user.workingDir &&
|
||||
canonicalDir(user.workingDir) === target;
|
||||
for (const user of input.live) {
|
||||
if (matches(user)) return true;
|
||||
}
|
||||
for (const user of input.persisted) {
|
||||
if (user.status === 'stopped') continue;
|
||||
if (matches(user)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
|
||||
const initial = setTimeout(() => {
|
||||
void sweepPasteImagesOnce(ctx);
|
||||
|
||||
+121
-32
@@ -3427,7 +3427,26 @@ class CodemanApp {
|
||||
*/
|
||||
_sendInputAsync(sessionId, input, opts) {
|
||||
if (!sessionId || !input) return;
|
||||
this._reliableSend(sessionId, input, opts?.useMux === true);
|
||||
const useMux = opts?.useMux === true;
|
||||
// Both transports refuse a frame over the server's limit (issue #484), and a
|
||||
// refused frame used to sit at the head of the durable queue for good. So an
|
||||
// oversized paste goes out as several in-limit frames, delivered in seq order
|
||||
// as one contiguous stream. A mux write is line-oriented (it strips newlines
|
||||
// and sends Enter on its own), so it is never split: refuse it instead.
|
||||
const limit = window.CodemanInputLimit;
|
||||
if (limit && input.length > limit.FRAME_MAX_CHARS) {
|
||||
if (useMux || input.length > limit.PASTE_MAX_CHARS) {
|
||||
const max = useMux ? limit.FRAME_MAX_CHARS : limit.PASTE_MAX_CHARS;
|
||||
this.showToast?.(
|
||||
`Input too large (${Math.ceil(input.length / 1024)} KB, limit ${Math.floor(max / 1024)} KB); not sent`,
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const frame of limit.split(input)) this._reliableSend(sessionId, frame, false);
|
||||
return;
|
||||
}
|
||||
this._reliableSend(sessionId, input, useMux);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3547,6 +3566,12 @@ class CodemanApp {
|
||||
// Session no longer exists — the input can never land. Drop it
|
||||
// rather than retry forever (not a "lost" prompt: the target is gone).
|
||||
this._ackDelivery(sessionId, rec.seq);
|
||||
} else if (resp && (resp.status === 400 || resp.status === 413)) {
|
||||
// The frame itself was refused, so a retry gets the same answer. Kept
|
||||
// queued, it was re-POSTed every 2 s forever and blocked every later
|
||||
// input for this session behind it (issue #484). 401/403 stay
|
||||
// transient: an expired login delivers fine once the user signs in.
|
||||
this._dropRejectedInput(sessionId, rec);
|
||||
} else {
|
||||
break; // offline / 5xx — leave queued; sweep + reconnect retry later
|
||||
}
|
||||
@@ -3557,6 +3582,12 @@ class CodemanApp {
|
||||
})();
|
||||
}
|
||||
|
||||
/** Drop a frame the server refused for good, and say so once. */
|
||||
_dropRejectedInput(sessionId, rec) {
|
||||
this._ackDelivery(sessionId, rec.seq);
|
||||
this.showToast?.(`Input refused by the server (${Math.ceil(rec.data.length / 1024)} KB); not sent`, 'error');
|
||||
}
|
||||
|
||||
/** Drop an ACKed record (by exact seq) and persist. */
|
||||
_ackDelivery(sessionId, seq) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
@@ -3601,6 +3632,13 @@ class CodemanApp {
|
||||
_onWsInputAck(seq, msg) {
|
||||
const sessionId = this._wsSessionId;
|
||||
if (!sessionId || !Number.isInteger(seq)) return;
|
||||
if (msg && msg.err) {
|
||||
// Refused for good (e.g. over the size limit): retrying cannot help.
|
||||
const rec = (this._pendingDeliveries.get(sessionId) || []).find((r) => r.seq === seq);
|
||||
if (rec) this._dropRejectedInput(sessionId, rec);
|
||||
else this._ackDelivery(sessionId, seq);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.dup) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
const rec = list && list.find((r) => r.seq === seq);
|
||||
@@ -3714,20 +3752,22 @@ class CodemanApp {
|
||||
if (saved && saved.pending) {
|
||||
for (const [s, recs] of Object.entries(saved.pending)) {
|
||||
if (Array.isArray(recs) && recs.length) {
|
||||
// Reset sentAt so they re-deliver promptly on this fresh load.
|
||||
this._pendingDeliveries.set(
|
||||
s,
|
||||
recs
|
||||
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
|
||||
.map((r) => ({
|
||||
seq: r.seq,
|
||||
data: r.data,
|
||||
useMux: !!r.useMux,
|
||||
ts: r.ts || Date.now(),
|
||||
tries: 0,
|
||||
sentAt: 0,
|
||||
}))
|
||||
);
|
||||
const frameMax = window.CodemanInputLimit?.FRAME_MAX_CHARS ?? Infinity;
|
||||
const kept = recs
|
||||
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
|
||||
// A frame over the server's limit can never be ACKed; one persisted
|
||||
// by an older build would otherwise come back on every load (#484).
|
||||
.filter((r) => r.data.length <= frameMax)
|
||||
// Reset sentAt so they re-deliver promptly on this fresh load.
|
||||
.map((r) => ({
|
||||
seq: r.seq,
|
||||
data: r.data,
|
||||
useMux: !!r.useMux,
|
||||
ts: r.ts || Date.now(),
|
||||
tries: 0,
|
||||
sentAt: 0,
|
||||
}));
|
||||
if (kept.length) this._pendingDeliveries.set(s, kept);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4878,9 +4918,10 @@ class CodemanApp {
|
||||
// `state` keys SESSION_ACTIVITY_RANK and the sort, while `status` stays idle
|
||||
// or busy for an exited pane by design, so without this the muted dot sits
|
||||
// beside a pill saying "idle". A pending alert still wins, exactly as it
|
||||
// does for the dot.
|
||||
const exited = !!paneExitLabel(session.paneExit) && (state === 'idle' || state === 'working');
|
||||
const exitAt = exited ? Number(session.paneExit.at) || 0 : 0;
|
||||
// does for the dot. The rule is `_mobileOverviewExit()`, shared with both
|
||||
// home screens so the three surfaces agree on which sessions have exited.
|
||||
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
|
||||
const exited = !!exit;
|
||||
return {
|
||||
state,
|
||||
exited,
|
||||
@@ -4891,13 +4932,7 @@ class CodemanApp {
|
||||
// state pill and never replaces it.
|
||||
watching: typeof session.watching === 'string' ? session.watching : '',
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
since: exitAt
|
||||
? { key: 'exited', at: exitAt }
|
||||
: exited
|
||||
? null
|
||||
: this._mobileOverviewSince
|
||||
? this._mobileOverviewSince(state, session)
|
||||
: null,
|
||||
since: exit ? exit.since : this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6285,10 +6320,15 @@ class CodemanApp {
|
||||
if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return;
|
||||
if (this.detachedSessions?.has(sessionId)) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
// A shell's full capture can be many megabytes. Replaying it from an
|
||||
// ordinary scroll gesture blocks xterm's main thread, so keep that cost
|
||||
// behind the explicit "Load full history" button.
|
||||
if (!force && session?.mode === 'shell') return;
|
||||
// A shell's full capture can be many megabytes, and replaying all of it from
|
||||
// an ordinary scroll gesture blocks xterm's main thread. So a shell scroll
|
||||
// pulls a BOUNDED window of tmux's full history (the same 1 MiB a tab switch
|
||||
// loads, but of the scrollback rather than the visible frame) and the
|
||||
// unbounded pull stays behind the "Load full history" button. Declining
|
||||
// outright left a shell pane about one screen of browser scrollback after any
|
||||
// burst, and the button only renders once a replay was truncated, so a young
|
||||
// shell tab had no way back to output tmux was still holding.
|
||||
const boundedShellPull = !force && session?.mode === 'shell';
|
||||
const now = Date.now();
|
||||
// Momentum scrolling fires this dozens of times per flick, and a burst of new
|
||||
// output is the normal reason to want a re-pull, so cooldown rather than latch.
|
||||
@@ -6301,7 +6341,12 @@ class CodemanApp {
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
const requestStartedAt = performance.now();
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
|
||||
const capture = await this._fetchTerminalCapture(
|
||||
boundedShellPull
|
||||
? `/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`
|
||||
: `/api/sessions/${sessionId}/terminal?full=1`,
|
||||
{ full: true }
|
||||
);
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const payload = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
@@ -6322,7 +6367,44 @@ class CodemanApp {
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
// history into the terminal the user is now looking at.
|
||||
if (!buffer || this.activeSessionId !== sessionId) return;
|
||||
if (this._replayWouldShrinkBuffer(buffer)) {
|
||||
const windowRows = this._estimateReplayRows(buffer, this.terminal.cols);
|
||||
// A bounded window no longer than the browser's buffer buys nothing, and
|
||||
// resetting to rewrite it would jump the viewport on every scroll that
|
||||
// outlasts the cooldown at the top. This runs BEFORE the downgrade guard
|
||||
// on purpose: that guard reads "smaller than the browser" as "tmux has
|
||||
// nothing more to give", which is true of an unbounded capture but not of a
|
||||
// window cut at the tail size, so a bounded window must never reach the
|
||||
// exhausted path, which would take Load full history off the banner while
|
||||
// tmux still holds the rest. Nothing was written here, so the banner state
|
||||
// is left as the load that produced it set it: re-labelling it from this
|
||||
// payload would call a terminal that holds ALL of a Load full history pull
|
||||
// "the most recent 1 MiB".
|
||||
//
|
||||
// A browser already at xterm's cap buys nothing either. xterm keeps at most
|
||||
// `scrollback + rows` rows (DEFAULT_SCROLLBACK 50k) while tmux keeps 100k
|
||||
// lines by default, so a 1 MiB window of short lines can render to more rows
|
||||
// than the browser can ever hold, and `windowRows <= rowsNow` then never
|
||||
// comes true: without this every scroll-to-top would reset and re-parse it.
|
||||
const rowsNow = this.terminal.buffer.active.length;
|
||||
const scrollbackCap = this.terminal.options?.scrollback || 0;
|
||||
const browserFull = scrollbackCap > 0 && rowsNow >= scrollbackCap + this.terminal.rows;
|
||||
if (boundedShellPull && (windowRows <= rowsNow || browserFull)) {
|
||||
// An untruncated window IS all of tmux's history, so nothing is missing,
|
||||
// and the next burst of output can put more in tmux than the browser has:
|
||||
// keep the normal 4 s cooldown. A truncated one is the opposite case, since
|
||||
// the gesture can never reach anything older than what the browser already
|
||||
// shows, and every ask costs the server a synchronous capture-pane of the
|
||||
// whole history (`tail` is applied after the capture): back off to 60 s.
|
||||
// A full browser backs off too, since no window can ever fit in it.
|
||||
// Trade-off: only a successful replay clears that latch, so a tab switch or
|
||||
// burst that shrinks the browser's buffer below the window can leave a
|
||||
// scroll-to-top inert for up to a minute. Load full history (`force`)
|
||||
// bypasses the cooldown, and the latch is bounded, never permanent.
|
||||
if (payload.truncated || browserFull) (this._fullHistoryRepullUseless ||= new Set()).add(sessionId);
|
||||
this._logScrollRouting?.('repull-skipped-bounded');
|
||||
return;
|
||||
}
|
||||
if (this._replayWouldShrinkBuffer(buffer, windowRows)) {
|
||||
timing.refused = true;
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
@@ -6333,7 +6415,14 @@ class CodemanApp {
|
||||
this._setHistoryTruncation(sessionId, { ...payload, exhausted: true });
|
||||
return;
|
||||
}
|
||||
this._setHistoryTruncation(sessionId, payload);
|
||||
// A bounded window that was cut is always recoverable: a capture over the
|
||||
// byte cap keeps `truncationReason: 'capped'` through the tail cut, and that
|
||||
// would tell the user the rest "cannot be recovered" and drop Load full
|
||||
// history, whose unbounded pull returns up to the cap itself.
|
||||
this._setHistoryTruncation(
|
||||
sessionId,
|
||||
boundedShellPull && payload.truncated ? { ...payload, truncationReason: 'tail' } : payload
|
||||
);
|
||||
this._fullHistoryRepullUseless?.delete(sessionId);
|
||||
const rowsBefore = this.terminal.buffer.active.length;
|
||||
const replayStartedAt = performance.now();
|
||||
|
||||
@@ -762,6 +762,49 @@ function resolveTerminalFontWeights(settings) {
|
||||
// without a terminal, a clipboard, or a browser.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Largest single input frame the server accepts, in UTF-16 code units.
|
||||
* ⚠️ Must equal MAX_INPUT_LENGTH in src/config/terminal-limits.ts (pinned by
|
||||
* test/input-size-limit.test.ts). Both transports reject a longer frame, and
|
||||
* before issue #484 the durable input queue retried such a frame forever.
|
||||
*/
|
||||
const INPUT_FRAME_MAX_CHARS = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Largest paste the client will deliver at all. Anything up to this is split
|
||||
* into INPUT_FRAME_MAX_CHARS frames that go out in seq order, so the PTY sees
|
||||
* one contiguous byte stream (bracketed-paste markers included). Past it the
|
||||
* input is refused with a toast rather than queued: every frame is persisted
|
||||
* and retried until ACKed, so a multi-megabyte paste would pin the queue.
|
||||
*/
|
||||
const INPUT_PASTE_MAX_CHARS = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Split input into frames no longer than `max` code units, never cutting a
|
||||
* surrogate pair in half (a lone surrogate reaches the PTY as U+FFFD).
|
||||
*
|
||||
* @param {string} data
|
||||
* @param {number} [max]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function splitInputFrames(data, max = INPUT_FRAME_MAX_CHARS) {
|
||||
if (typeof data !== 'string' || data.length === 0) return [];
|
||||
if (!(max >= 2)) max = 2;
|
||||
if (data.length <= max) return [data];
|
||||
const frames = [];
|
||||
let start = 0;
|
||||
while (start < data.length) {
|
||||
let end = Math.min(start + max, data.length);
|
||||
if (end < data.length) {
|
||||
const code = data.charCodeAt(end - 1);
|
||||
if (code >= 0xd800 && code <= 0xdbff) end--; // keep the pair together
|
||||
}
|
||||
frames.push(data.slice(start, end));
|
||||
start = end;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on an AUTO-copied selection.
|
||||
*
|
||||
@@ -940,6 +983,11 @@ if (typeof window !== 'undefined') {
|
||||
compare: compareSessionActivity,
|
||||
sort: sortSessionsByActivity,
|
||||
};
|
||||
window.CodemanInputLimit = {
|
||||
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
||||
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
||||
split: splitInputFrames,
|
||||
};
|
||||
window.CodemanAutoCopy = {
|
||||
decide: decideAutoCopy,
|
||||
MAX_CHARS: AUTO_COPY_MAX_CHARS,
|
||||
|
||||
@@ -201,6 +201,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const session = this.sessions.get(id);
|
||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
||||
// Guarded: a stale cached mobile-overview.js may predate the helper.
|
||||
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
|
||||
const mode = session.mode || 'claude';
|
||||
return {
|
||||
id,
|
||||
@@ -211,7 +213,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
|
||||
// What the row's dot, accent and pill show. It differs from `state` only
|
||||
// for an exited agent (Ark0N/Codeman#446), whose state still sorts it.
|
||||
display: exit ? 'exited' : state,
|
||||
pill: exit ? 'exited' : HOME_SESSIONS_PILL_LABEL[state] || state,
|
||||
// What the pane's footer says is still running in the background, straight off
|
||||
// the session payload. Same field, same meaning as on the phone overview.
|
||||
watching: typeof session.watching === 'string' ? session.watching : '',
|
||||
@@ -224,7 +229,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
// "how long has it been like this", resolved by the phone overview's
|
||||
// helper so both home screens label the same stamp with the same word.
|
||||
since: this._mobileOverviewSince(state, session),
|
||||
since: exit ? exit.since : this._mobileOverviewSince(state, session),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -392,7 +397,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
_buildHomeSessionRow(row) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'home-sessions-row home-sessions-row--' + row.state;
|
||||
const display = row.display || row.state;
|
||||
item.className = 'home-sessions-row home-sessions-row--' + display;
|
||||
item.dataset.hsAction = 'session';
|
||||
item.dataset.hsSession = row.id;
|
||||
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
||||
@@ -409,7 +415,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'home-sessions-dot home-sessions-dot--' + row.state;
|
||||
dot.className = 'home-sessions-dot home-sessions-dot--' + display;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(dot);
|
||||
|
||||
@@ -441,7 +447,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'home-sessions-pill home-sessions-pill--' + row.state;
|
||||
pill.className = 'home-sessions-pill home-sessions-pill--' + display;
|
||||
// Skipped by i18n on purpose: generic single words ("idle", "done", "error")
|
||||
// that collide with state strings on other surfaces.
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
|
||||
@@ -106,16 +106,20 @@
|
||||
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
|
||||
'Select case': '选择案例',
|
||||
'Select Case': '选择案例',
|
||||
'Search cases': '搜索案例',
|
||||
'No matching cases': '没有匹配的案例',
|
||||
'All cases': '全部案例',
|
||||
'No directory': '未选择目录',
|
||||
Run: '运行',
|
||||
'Run Claude Code': '运行 Claude Code',
|
||||
'Run OpenCode': '运行 OpenCode',
|
||||
'Run Codex': '运行 Codex',
|
||||
'Run Gemini': '运行 Gemini',
|
||||
'Run Antigravity': '运行 Antigravity',
|
||||
'Run Pi': '运行 Pi',
|
||||
'Run Grok': '运行 Grok',
|
||||
'Run DeepSeek': '运行 DeepSeek',
|
||||
'Run OMP': '运行 OMP',
|
||||
'Run Shell': '运行 Shell',
|
||||
'Select AI backend': '选择 AI 后端',
|
||||
'Create New Case': '新建案例',
|
||||
|
||||
+74
-60
@@ -451,42 +451,11 @@
|
||||
<h1 class="welcome-title">Codeman</h1>
|
||||
<p class="welcome-desc">Manage AI Coding tools in persistent tmux sessions.</p>
|
||||
<div class="welcome-actions">
|
||||
<button class="welcome-btn welcome-btn-claude" id="welcomeClaudeBtn" style="display: none;" onclick="app.setRunMode('claude'); app.runClaude()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Claude Code
|
||||
</button>
|
||||
<div class="welcome-cli-actions" id="welcomeCliActions"></div>
|
||||
<button class="welcome-btn welcome-btn-tunnel" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
Cloudflare Tunnel
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-opencode" id="welcomeOpencodeBtn" style="display: none;" onclick="app.setRunMode('opencode'); app.runOpenCode()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OpenCode
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-antigravity" id="welcomeAntigravityBtn" style="display: none;" onclick="app.setRunMode('antigravity'); app.runAntigravity()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Antigravity
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-gemini" id="welcomeGeminiBtn" style="display: none;" onclick="app.setRunMode('gemini'); app.runGemini()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Gemini
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-pi" id="welcomePiBtn" style="display: none;" onclick="app.setRunMode('pi'); app.runPi()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Pi
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-grok" id="welcomeGrokBtn" style="display: none;" onclick="app.setRunMode('grok'); app.runGrok()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Grok
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-deepseek" id="welcomeDeepSeekBtn" style="display: none;" onclick="app.setRunMode('deepseek'); app.runDeepSeek()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run DeepSeek
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-omp" id="welcomeOmpBtn" style="display: none;" onclick="app.setRunMode('omp'); app.runOmp()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OMP
|
||||
</button>
|
||||
</div>
|
||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||
@@ -648,39 +617,13 @@
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
<div class="run-mode-menu" id="runModeMenu">
|
||||
<button class="run-mode-option" data-mode="claude" onclick="app.setRunMode('claude')">
|
||||
<span class="run-mode-dot claude"></span>Claude Code
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
|
||||
<span class="run-mode-dot opencode"></span>OpenCode
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
|
||||
<span class="run-mode-dot codex"></span>Codex
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
|
||||
<span class="run-mode-dot gemini"></span>Gemini
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="antigravity" onclick="app.setRunMode('antigravity')">
|
||||
<span class="run-mode-dot antigravity"></span>Antigravity
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="pi" onclick="app.setRunMode('pi')">
|
||||
<span class="run-mode-dot pi"></span>Pi
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
|
||||
<span class="run-mode-dot grok"></span>Grok
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="deepseek" onclick="app.setRunMode('deepseek')">
|
||||
<span class="run-mode-dot deepseek"></span>DeepSeek
|
||||
</button>
|
||||
<div class="run-mode-cli-options" id="runModeCliOptions"></div>
|
||||
<!-- Shown only when `dsh` is installed but no pane-capable profile is:
|
||||
DeepSeek ships no terminal front door, so the fix is an install,
|
||||
not a greyed-out entry the user cannot act on. -->
|
||||
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
|
||||
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="omp" onclick="app.setRunMode('omp')">
|
||||
<span class="run-mode-dot omp"></span>OMP
|
||||
</button>
|
||||
<!-- Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): one
|
||||
generated entry per (harness, saved endpoint) pair, e.g. "Claude Code
|
||||
(llama.cpp)". Built entirely by _refreshCustomModelRunOptions() — hidden
|
||||
@@ -2194,6 +2137,8 @@
|
||||
<option value="claude-fable-5-1[1m]" data-variant="1m" data-base="claude-fable-5-1">Fable 5.1 (1M context)</option>
|
||||
<option value="claude-fable-5" data-meta="Most powerful" data-base="claude-fable-5" data-ctx="1">Fable 5</option>
|
||||
<option value="claude-fable-5[1m]" data-variant="1m" data-base="claude-fable-5">Fable 5 (1M context)</option>
|
||||
<option value="claude-opus-5-5" data-meta="Latest Opus" data-base="claude-opus-5-5" data-ctx="1">Opus 5.5</option>
|
||||
<option value="claude-opus-5-5[1m]" data-variant="1m" data-base="claude-opus-5-5">Opus 5.5 (1M context)</option>
|
||||
<option value="opus" data-meta="Most capable" data-base="opus" data-ctx="1">Opus</option>
|
||||
<option value="opus[1m]" data-variant="1m" data-base="opus">Opus (1M context)</option>
|
||||
<option value="claude-opus-4-6" data-meta="Previous generation" data-base="claude-opus-4-6" data-ctx="1">Opus 4.6</option>
|
||||
@@ -2204,7 +2149,7 @@
|
||||
<div class="set-row" id="appSettingsContextRow" data-search="1m context window opus long">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">1M context window</span>
|
||||
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5.1, Fable 5, Opus and Opus 4.6.</span>
|
||||
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5.1, Fable 5, Opus 5.5, Opus and Opus 4.6.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsOpusContext1m"><span class="slider"></span></label>
|
||||
</div>
|
||||
@@ -2246,6 +2191,7 @@
|
||||
<option value="">Default (CLI default)</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1 (Latest)</option>
|
||||
<option value="claude-fable-5">Fable 5 (Most powerful)</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5 (Latest Opus)</option>
|
||||
<option value="opus">Opus (Most capable)</option>
|
||||
<option value="sonnet">Sonnet (Balanced)</option>
|
||||
<option value="haiku">Haiku (Fast & cheap)</option>
|
||||
@@ -2261,6 +2207,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-mini">
|
||||
@@ -2272,6 +2219,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-mini">
|
||||
@@ -2283,6 +2231,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-mini">
|
||||
@@ -2294,6 +2243,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2370,6 +2320,64 @@
|
||||
</div>
|
||||
<p class="set-section-blurb">Launch flags for the CLIs Codeman spawns.</p>
|
||||
|
||||
<div class="set-group" id="cliManagementGroup">
|
||||
<div class="set-group-head"><h4>CLI management</h4><span class="set-scope">synced</span></div>
|
||||
<p class="set-group-hint">Enable/disable a CLI, install one that's missing, or add your own — without hand-editing ~/.codeman/clis.json.</p>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="cli management enable disable install custom">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Enable CLI management</span>
|
||||
<span class="set-row-desc">Adds the list below and its write endpoints. Off by default: this changes machine configuration, not just what you see.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCliManagement" onchange="app.applyCliManagementVisibility()"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="cliListGroup" style="display: none;">
|
||||
<div class="set-group-head"><h4>Installed CLIs</h4></div>
|
||||
<div class="set-group-body">
|
||||
<div id="cliListRows"></div>
|
||||
<div class="set-row" data-search="add custom cli">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Add a custom CLI</span>
|
||||
<span class="set-row-desc">A launch command Codeman doesn't ship — id, label, badge, binary and its bare argv.</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-xs" id="cliCustomAddToggle" onclick="app.openCliCustomForm()">Add</button>
|
||||
</div>
|
||||
<form id="cliCustomForm" style="display: none;" onsubmit="app.submitCliCustomForm(event)">
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Id</span></div>
|
||||
<input type="text" id="cliCustomId" class="set-input" placeholder="my-cli" maxlength="24">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Label</span></div>
|
||||
<input type="text" id="cliCustomLabel" class="set-input" placeholder="My CLI" maxlength="60">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Badge</span></div>
|
||||
<input type="text" id="cliCustomBadge" class="set-input" placeholder="MC" maxlength="6">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Binary</span></div>
|
||||
<input type="text" id="cliCustomBinary" class="set-input" placeholder="my-cli">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Launch argv</span>
|
||||
<span class="set-row-desc">Space-separated bare words, e.g. "my-cli --flag". No quoting or shell syntax.</span>
|
||||
</div>
|
||||
<input type="text" id="cliCustomArgv" class="set-input" placeholder="my-cli --flag">
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<button type="submit" class="btn btn-xs" id="cliCustomSubmit">Create</button>
|
||||
<button type="button" class="btn btn-xs" id="cliCustomCancel" onclick="app.closeCliCustomForm()">Cancel</button>
|
||||
</div>
|
||||
<div id="cliCustomFormError" class="set-row-desc" style="color: var(--error, #e5484d); display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Claude</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
@@ -3167,6 +3175,7 @@
|
||||
<h2>Manage</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">Reorder or remove cases, and pick up anything exported from a docker case.</p>
|
||||
<input type="search" id="caseManageSearch" class="set-input" placeholder="Search cases by name or path" autocomplete="off" spellcheck="false" aria-label="Search cases" oninput="app.setCaseManageFilter(this.value)" style="margin-bottom: 8px;">
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
@@ -3196,7 +3205,12 @@
|
||||
<h3>Select Case</h3>
|
||||
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">×</button>
|
||||
</div>
|
||||
<div class="mobile-case-picker-search">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="search" id="mobileCaseSearch" placeholder="Search cases" aria-label="Search cases" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go" oninput="app.filterMobileCases()" onkeydown="app.onMobileCaseSearchKey(event)">
|
||||
</div>
|
||||
<div class="mobile-case-picker-body">
|
||||
<div class="mobile-case-empty" id="mobileCaseEmpty" hidden>No matching cases</div>
|
||||
<div class="mobile-case-list" id="mobileCaseList">
|
||||
<!-- Cases populated by JS -->
|
||||
</div>
|
||||
|
||||
@@ -42,13 +42,14 @@ const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 599px)';
|
||||
|
||||
/** How many past conversations show before the "Show all" toggle. */
|
||||
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
||||
const SHELL_KIND = 'shell';
|
||||
|
||||
/**
|
||||
* Backends offered by the Run picker, mirroring the toolbar's run-mode menu
|
||||
* (`#runModeMenu` in index.html). `short` is the badge on the Run button itself.
|
||||
*/
|
||||
const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'claude', label: 'Claude Code', short: 'Claude' },
|
||||
{ mode: 'claude', label: 'Claude Code', short: 'Claude Code' },
|
||||
{ mode: 'opencode', label: 'OpenCode', short: 'OpenCode' },
|
||||
{ mode: 'codex', label: 'Codex', short: 'Codex' },
|
||||
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
|
||||
@@ -68,6 +69,23 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
const WATCHING_BADGE_TEXT = 'watching';
|
||||
const watchingBadgeTitle = (label) => 'Still running in the background: ' + label;
|
||||
|
||||
function mobileOverviewRunModes() {
|
||||
const catalog =
|
||||
typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
||||
if (catalog.length === 0) return MOBILE_OVERVIEW_RUN_MODES;
|
||||
return catalog
|
||||
.filter((entry) => entry.enabled)
|
||||
.map((entry) => ({
|
||||
mode: entry.id,
|
||||
label: entry.kind === SHELL_KIND ? 'Terminal / Shell' : entry.label,
|
||||
// The registry `label`, not `shortBadge`: the Run button has always shown a word
|
||||
// ("Claude", "Codex", "Shell"), and every stock label IS that word, so this stays
|
||||
// identical to MOBILE_OVERVIEW_RUN_MODES above. `shortBadge` is the two-letter tab
|
||||
// code ("CC", "CX"), which read as a regression on the button.
|
||||
short: entry.label,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
|
||||
const MOBILE_OVERVIEW_PILL_LABEL = {
|
||||
needs: 'needs you',
|
||||
@@ -143,6 +161,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
|
||||
},
|
||||
|
||||
/**
|
||||
* The exited-agent override for one row (Ark0N/Codeman#446), or null when
|
||||
* the row shows its state as usual.
|
||||
*
|
||||
* The server publishes `session.paneExit` once the agent inside a local tmux
|
||||
* pane has exited, while `status` stays `idle` or `busy` by design. So a row
|
||||
* classified as idle or working may really be a pane with nothing running
|
||||
* in it. This overrides what the row SHOWS, never its `state`: `state` still
|
||||
* picks the section and the sort, the way `_sidebarRichRow()` (app.js) does
|
||||
* for the detailed sidebar and rail. A pending alert still wins, because a
|
||||
* human being blocked outranks the agent having exited.
|
||||
*
|
||||
* Shared by the phone overview, the desktop home rail and the rich tab rows,
|
||||
* so the three cannot disagree about which sessions have exited.
|
||||
*
|
||||
* Guarded like every other cross-file call: `paneExitLabel()` lives in
|
||||
* app.js, and a stale cached app.js must degrade to no override, not throw.
|
||||
*
|
||||
* @returns {{since: {key: string, at: number}|null}|null}
|
||||
*/
|
||||
_mobileOverviewExit(state, session) {
|
||||
if (state !== 'idle' && state !== 'working') return null;
|
||||
if (typeof paneExitLabel !== 'function' || !paneExitLabel(session.paneExit)) return null;
|
||||
// `at` is when this server first saw the pane dead, which is what "exited
|
||||
// 2m" should measure. A row without it shows no duration at all rather
|
||||
// than a working or idle stamp that no longer describes the pane.
|
||||
const at = Number(session.paneExit.at) || 0;
|
||||
return { since: at ? { key: 'exited', at } : null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Longest-prefix match of a workingDir against the case list, so a session
|
||||
* started in a subdirectory still belongs to its case. Mirrors the matching in
|
||||
@@ -184,6 +232,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const rows = sessions.map((session) => {
|
||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
|
||||
const exit = this._mobileOverviewExit(state, session);
|
||||
const orderIndex = order.indexOf(session.id);
|
||||
return {
|
||||
id: session.id,
|
||||
@@ -192,7 +241,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
|
||||
// What the row's dot, accent and pill show. It differs from `state` only
|
||||
// for an exited agent, whose state still decides the section and sort.
|
||||
display: exit ? 'exited' : state,
|
||||
pill: exit ? 'exited' : MOBILE_OVERVIEW_PILL_LABEL[state] || state,
|
||||
// What the pane's own footer says is still running in the background ("1 monitor",
|
||||
// "2 shells"), straight off the session payload. A row that has one is quiet
|
||||
// because the agent is waiting for that, not because it is waiting for you.
|
||||
@@ -204,7 +256,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// pair resolved for DISPLAY, and the two must not drift apart.
|
||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
since: this._mobileOverviewSince(state, session),
|
||||
since: exit ? exit.since : this._mobileOverviewSince(state, session),
|
||||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||||
};
|
||||
});
|
||||
@@ -527,7 +579,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const runMode = document.createElement('span');
|
||||
runMode.className = 'mobile-overview-run-mode';
|
||||
runMode.setAttribute('data-i18n-skip', '');
|
||||
runMode.textContent = MOBILE_OVERVIEW_RUN_MODES.find((m) => m.mode === mode)?.short || mode;
|
||||
runMode.textContent = mobileOverviewRunModes().find((m) => m.mode === mode)?.short || mode;
|
||||
run.appendChild(runMode);
|
||||
group.appendChild(run);
|
||||
|
||||
@@ -582,7 +634,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
menu.className = 'mobile-overview-run-menu';
|
||||
const current = this.runMode || 'claude';
|
||||
|
||||
for (const entry of MOBILE_OVERVIEW_RUN_MODES) {
|
||||
for (const entry of mobileOverviewRunModes()) {
|
||||
if (entry.mode !== 'shell' && !this.isCliAvailable(entry.mode)) continue;
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
@@ -680,12 +732,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
_buildMobileOverviewRow(row) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'mobile-overview-row mobile-overview-row--' + row.state;
|
||||
const display = row.display || row.state;
|
||||
item.className = 'mobile-overview-row mobile-overview-row--' + display;
|
||||
item.dataset.moAction = 'session';
|
||||
item.dataset.moSession = row.id;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state;
|
||||
dot.className = 'mobile-overview-dot mobile-overview-dot--' + display;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(dot);
|
||||
|
||||
@@ -718,7 +771,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state;
|
||||
pill.className = 'mobile-overview-pill mobile-overview-pill--' + display;
|
||||
// Skipped by i18n on purpose: the labels are generic single words ("idle",
|
||||
// "done", "error") that collide with state strings on other surfaces.
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
|
||||
+128
-18
@@ -332,6 +332,39 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
}
|
||||
|
||||
/* Edge fade for the phone's header tab strip (used in the block below).
|
||||
Registered so the keyframes can interpolate them as lengths; @property is
|
||||
only valid at the top level, hence out here. */
|
||||
@property --tab-strip-fade-start {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@property --tab-strip-fade-end {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
/* Driven by the strip's own scroll position, not by time: at the start only the
|
||||
end edge fades, at the end only the start edge, and in between both. */
|
||||
@keyframes tab-strip-edge-fade {
|
||||
0% {
|
||||
--tab-strip-fade-start: 0px;
|
||||
--tab-strip-fade-end: 28px;
|
||||
}
|
||||
10%,
|
||||
90% {
|
||||
--tab-strip-fade-start: 28px;
|
||||
--tab-strip-fade-end: 28px;
|
||||
}
|
||||
100% {
|
||||
--tab-strip-fade-start: 28px;
|
||||
--tab-strip-fade-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Phone Breakpoint (<600px)
|
||||
============================================================================ */
|
||||
@@ -652,7 +685,7 @@ html.mobile-init .file-browser-panel {
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
max-height: 36px;
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -660,6 +693,36 @@ html.mobile-init .file-browser-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Fade the strip's edges while there is more to scroll to, so the tab that
|
||||
does not fit dissolves into the edge instead of being cut mid-word against
|
||||
the connection dot. Scroll-driven, no JS: the timeline is the strip's own
|
||||
inline scroll (keyframes + registered properties above this block). A strip
|
||||
that does not overflow has an INACTIVE timeline, so the animation applies
|
||||
nothing and both widths stay at their registered 0px, which is no mask at
|
||||
all. Browsers without scroll timelines skip the block and keep the hard
|
||||
edge. Header only: in sidebar layout the same list scrolls vertically. */
|
||||
@supports (animation-timeline: scroll()) {
|
||||
.header .session-tabs {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 var(--tab-strip-fade-start),
|
||||
#000 calc(100% - var(--tab-strip-fade-end)),
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
#000 var(--tab-strip-fade-start),
|
||||
#000 calc(100% - var(--tab-strip-fade-end)),
|
||||
transparent
|
||||
);
|
||||
/* The shorthand resets animation-timeline, so the timeline comes after. */
|
||||
animation: tab-strip-edge-fade linear both;
|
||||
animation-timeline: scroll(self inline);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smaller tabs for mobile */
|
||||
.session-tab {
|
||||
flex-shrink: 0;
|
||||
@@ -671,10 +734,46 @@ html.mobile-init .file-browser-panel {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Smaller status indicator on mobile */
|
||||
/* Every tab in the header strip is a chip, not only the active one. Left
|
||||
transparent, the strip read as a row of disabled labels: grey 11px text
|
||||
floating in unmarked gaps, with nothing saying "tap me". Fill and border
|
||||
come from the skin's control tokens, so the four light skins (which repaint
|
||||
the header with --glass-bg) get a matching chip with no override block, and
|
||||
the active tab's !important fill and border in styles.css still win.
|
||||
`:where(.header)` keeps this at (0,1,0): the per-colour left border
|
||||
(`.session-tab[data-color="red"]`, (0,2,0)) must still outrank the
|
||||
border-color here, and in sidebar layout the list leaves the header, so
|
||||
its rows are untouched. */
|
||||
:where(.header) .session-tab {
|
||||
border-radius: 8px;
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:where(.header) .session-tab .tab-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Only the active tab shows its action icons on a phone (see below), so on
|
||||
every other tab the container is empty but still a flex item, and its gap
|
||||
made the chip visibly wider on the right than on the left. */
|
||||
:where(.header) .session-tab:not(.active) .tab-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The boxed digit is the Alt+1..9 shortcut hint. A phone has no Alt key, so
|
||||
here it was only a second grey box inside every tab, and 20px of the name's
|
||||
width. Every header tab is therefore numberless on a phone, which is the
|
||||
case the active-tab reserve below is already sized for. */
|
||||
:where(.header) .session-tab .tab-number {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Status dot: 6px so an idle green reads at arm's length (4px was a speck). */
|
||||
.session-tab .tab-status {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
|
||||
@@ -710,9 +809,12 @@ html.mobile-init .file-browser-panel {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Truncate tab names more aggressively on mobile */
|
||||
/* Truncate tab names on mobile. 80px, not the old 50px: session names share
|
||||
a `w1-` style prefix, and at 50px "w1-ingest-pipeline" became "w1-inge…"
|
||||
and a clipped tab just "w1-", which says nothing about which session it is.
|
||||
The 20px the hidden tab number gave back pays for most of the difference. */
|
||||
.session-tab .tab-name {
|
||||
max-width: 50px;
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -726,20 +828,19 @@ html.mobile-init .file-browser-panel {
|
||||
difference instead, which costs a little strip space on exactly one tab
|
||||
and keeps tap-to-switch the majority of it.
|
||||
|
||||
⚠️ The floor is set by the 10th tab onward, NOT by the numbered tabs you
|
||||
are looking at. `.tab-number` is rendered only for `_tabIdx < 9` (app.js),
|
||||
so tab 10 loses 16px + a 4px gap off its left and its centre sits 10px
|
||||
further right. The centre clears the icons when
|
||||
⚠️ The floor is set by a NUMBERLESS tab. `.tab-number` is rendered only
|
||||
for `_tabIdx < 9` (app.js), and the header hides it on phones altogether
|
||||
(above), so every phone tab is that case now; a numbered one would sit 10px
|
||||
further left and hide the problem. The centre clears the icons when
|
||||
|
||||
reserved > icons + rightEdge - leftRunUp - gap
|
||||
= 50 + 9 - 17 - 4 = 38px
|
||||
= 50 + 9 - 19 - 4 = 36px
|
||||
|
||||
with icons = gear 32 + close 20 - close's -2px margin, leftRunUp = border 1
|
||||
+ padding 8 + status dot 4 + gap 4, and rightEdge = padding 8 + border 1.
|
||||
Hit testing snaps to whole pixels, so 39px still lands on the gear: the
|
||||
practical floor is 40px and 44px keeps 4px of headroom. A NUMBERED tab
|
||||
clears it at 20px, so reasoning from the tabs on screen is exactly what
|
||||
would put the centre back on the gear. Pinned by
|
||||
+ padding 8 + status dot 6 + gap 4, and rightEdge = padding 8 + border 1.
|
||||
Hit testing snaps to whole pixels, so a centre half a pixel short still
|
||||
lands on the gear: the practical floor was measured at 40px (with the
|
||||
older 4px dot) and 44px keeps headroom. Pinned by
|
||||
test/mobile-tab-tap-zones.test.ts. */
|
||||
.session-tab.active .tab-name {
|
||||
min-width: 44px;
|
||||
@@ -2165,9 +2266,11 @@ html.mobile-init .file-browser-panel {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* The footer already reserves the home-indicator inset; padding the sheet too
|
||||
counted it twice and left a dead band under Create New Case. */
|
||||
.mobile-case-picker-sheet {
|
||||
max-height: 60vh;
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
max-height: 80vh;
|
||||
max-height: 80dvh;
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@@ -2978,6 +3081,13 @@ html.mobile-init .file-browser-panel {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* An exited agent (Ark0N/Codeman#446): neutral, since nothing is running behind
|
||||
the row. The dot and the row take `--exited` too and keep their base rules. */
|
||||
.mobile-overview-pill--exited {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Accent, and none of the three above: a session watching work it started itself
|
||||
is not asking the user for anything, and red and yellow are what say it is. */
|
||||
.mobile-overview-pill--watching {
|
||||
|
||||
@@ -672,6 +672,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
gitBranch: s.gitBranch,
|
||||
worktreeName: s.worktreeName,
|
||||
worktreeRepo: s.worktreeRepo,
|
||||
mode: s.mode,
|
||||
claudeSessionId: s.claudeSessionId,
|
||||
resumeId: s.resumeId,
|
||||
};
|
||||
const isLive = !!this.sessions?.has?.(s.sessionId);
|
||||
const item = this._buildHistoryItem(record, this.cases, {
|
||||
|
||||
+217
-19
@@ -122,7 +122,23 @@ const RUN_MODE_LAUNCH = {
|
||||
* (`openSessionOptions`), guaranteed to drift from each other the moment a
|
||||
* ninth CLI landed in one and not the other.
|
||||
*/
|
||||
/** How often the OPEN case picker re-reads /api/cases (it also refreshes once on open). */
|
||||
const CASE_PICKER_REFRESH_MS = 5000;
|
||||
|
||||
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
||||
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
|
||||
|
||||
function registryCliCatalog() {
|
||||
return typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
||||
}
|
||||
|
||||
function registryCliById(id) {
|
||||
return registryCliCatalog().find((entry) => entry.id === id);
|
||||
}
|
||||
|
||||
function isExternalCliRunMode(mode) {
|
||||
return EXTERNAL_CLI_MODES.has(mode) || registryCliById(mode)?.kind === 'agent';
|
||||
}
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
@@ -233,16 +249,74 @@ Object.assign(CodemanApp.prototype, {
|
||||
const input = document.getElementById('quickStartCaseSearch');
|
||||
const list = document.getElementById('quickStartCaseList');
|
||||
if (!input || !list) return;
|
||||
const wasOpen = this._casePickerOpen === true;
|
||||
this._casePickerOpen = true;
|
||||
this._casePickerFilter = filter;
|
||||
this._casePickerActiveIndex = 0;
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
this.renderCasePickerList();
|
||||
// Every keystroke re-enters here, so only the closed -> open transition
|
||||
// refreshes and arms the timer; typing must not fire a fetch per key.
|
||||
if (!wasOpen) this._startCasePickerRefresh();
|
||||
},
|
||||
|
||||
/** Re-read the case list while the picker is open, so folders deleted or created on disk show up without a page reload. */
|
||||
_startCasePickerRefresh() {
|
||||
void this.refreshCasePickerCases();
|
||||
if (this._casePickerRefreshTimer) return;
|
||||
this._casePickerRefreshTimer = setInterval(() => void this.refreshCasePickerCases(), CASE_PICKER_REFRESH_MS);
|
||||
},
|
||||
|
||||
_stopCasePickerRefresh() {
|
||||
if (this._casePickerRefreshTimer) clearInterval(this._casePickerRefreshTimer);
|
||||
this._casePickerRefreshTimer = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Lighter than loadQuickStartCases(): that one closes the picker and re-picks a
|
||||
* selection, which would yank the list away from someone mid-browse. This only
|
||||
* swaps the data and repaints, and does nothing when the list is unchanged.
|
||||
*/
|
||||
async refreshCasePickerCases() {
|
||||
if (this._casePickerRefreshInFlight) return;
|
||||
this._casePickerRefreshInFlight = true;
|
||||
try {
|
||||
const res = await fetch('/api/cases');
|
||||
if (!res.ok) return;
|
||||
const cases = (await res.json()).data;
|
||||
if (!Array.isArray(cases) || !this._casePickerOpen) return;
|
||||
const signature = list => JSON.stringify((list || []).map(c => [c.name, c.path, c.location]));
|
||||
if (signature(cases) === signature(this.cases)) return;
|
||||
this.cases = cases;
|
||||
|
||||
const select = document.getElementById('quickStartCase');
|
||||
if (select) {
|
||||
const previous = select.value;
|
||||
this.renderQuickStartCaseSelectOptions(select, this.getCasePickerOptions());
|
||||
if (cases.some(c => c.name === previous)) {
|
||||
select.value = previous;
|
||||
} else if (cases.length > 0) {
|
||||
// The selected case was removed on disk: fall back the way the initial
|
||||
// load does, without saving it as the user's last-used case.
|
||||
const fallback = cases.find(c => c.name === 'testcase') || cases[0];
|
||||
select.value = fallback.name;
|
||||
this.updateDirDisplayForCase(fallback.name);
|
||||
this.updateMobileCaseLabel(fallback.name);
|
||||
this.updateCasePickerInput(fallback.name);
|
||||
}
|
||||
}
|
||||
this.renderCasePickerList();
|
||||
} catch {
|
||||
// A failed poll leaves the list as it was; the next tick retries.
|
||||
} finally {
|
||||
this._casePickerRefreshInFlight = false;
|
||||
}
|
||||
},
|
||||
|
||||
closeCasePicker() {
|
||||
const input = document.getElementById('quickStartCaseSearch');
|
||||
const list = document.getElementById('quickStartCaseList');
|
||||
this._stopCasePickerRefresh();
|
||||
this._casePickerOpen = false;
|
||||
this._casePickerFilter = '';
|
||||
input?.setAttribute('aria-expanded', 'false');
|
||||
@@ -511,7 +585,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
}
|
||||
if (mode === 'claude' || !EXTERNAL_CLI_MODES.has(mode)) {
|
||||
if (mode === 'claude' || !isExternalCliRunMode(mode)) {
|
||||
return await this.runClaude();
|
||||
}
|
||||
return await this._runCliMode(mode);
|
||||
@@ -544,6 +618,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
e?.stopPropagation();
|
||||
const menu = document.getElementById('runModeMenu');
|
||||
if (!menu) return;
|
||||
this.renderRegistryRunOptions();
|
||||
menu.classList.toggle('active');
|
||||
// Update selected state
|
||||
menu.querySelectorAll('.run-mode-option').forEach(btn => {
|
||||
@@ -602,13 +677,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// reporting it as a fault hid every agent mode on a freshly linked Docker case
|
||||
// behind "start it yourself first", for a container Codeman was about to create.
|
||||
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (!btn) continue;
|
||||
for (const option of menu.querySelectorAll('.run-mode-option[data-mode]')) {
|
||||
const mode = option.dataset.mode;
|
||||
if (!mode || mode === 'shell') continue;
|
||||
let available;
|
||||
if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true;
|
||||
else available = this.isCliAvailable(mode);
|
||||
btn.style.display = available ? 'flex' : 'none';
|
||||
option.style.display = available ? 'flex' : 'none';
|
||||
}
|
||||
this._renderRunModeNotice(menu, probeError);
|
||||
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
|
||||
@@ -627,6 +702,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
|
||||
},
|
||||
|
||||
/** Render every enabled agent entry from the server's registry projection. */
|
||||
renderRegistryRunOptions() {
|
||||
const container = document.getElementById('runModeCliOptions');
|
||||
if (!container) return;
|
||||
const catalog = registryCliCatalog();
|
||||
if (catalog.length === 0) return; // cached pages from before the catalog keep their static fallback.
|
||||
container.replaceChildren();
|
||||
for (const cli of catalog) {
|
||||
if (cli.kind !== 'agent' || !cli.enabled) continue;
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'run-mode-option';
|
||||
option.dataset.mode = cli.id;
|
||||
option.onclick = () => this.setRunMode(cli.id);
|
||||
const dot = document.createElement('span');
|
||||
dot.className = `run-mode-dot ${cli.id}`;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
option.appendChild(dot);
|
||||
option.append(cli.label);
|
||||
container.appendChild(option);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generates the Run menu's Custom Model Endpoint entries
|
||||
* (docs/custom-model-endpoints-plan.md): one button per (capable harness, saved
|
||||
@@ -1640,7 +1738,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
const registryEntry = registryCliById(mode);
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : registryEntry ? `Run ${registryEntry.shortBadge}` : 'Run';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1667,7 +1766,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_initRunMode() {
|
||||
try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; }
|
||||
this.renderRegistryRunOptions();
|
||||
let savedMode = 'claude';
|
||||
try { savedMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { /* localStorage unavailable */ }
|
||||
// Go through the setter so a CLI disabled after the previous visit, or a
|
||||
// removed custom CLI, cannot survive in localStorage as a runnable mode.
|
||||
this.runMode = savedMode;
|
||||
this._applyRunMode();
|
||||
},
|
||||
|
||||
@@ -2131,7 +2235,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
* tests assert on that name directly too.
|
||||
*/
|
||||
async _runCliMode(mode) {
|
||||
const entry = RUN_MODE_LAUNCH[mode];
|
||||
const catalogEntry = registryCliById(mode);
|
||||
const entry = RUN_MODE_LAUNCH[mode] ||
|
||||
(catalogEntry && {
|
||||
label: catalogEntry.label,
|
||||
installHint: `${catalogEntry.label} is not available on this host.`,
|
||||
supportsCustomModel: false,
|
||||
buildConfig: () => null,
|
||||
});
|
||||
if (!entry) throw new Error(`Unknown run mode: ${mode}`);
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run the CLI on the OTHER side — the local status
|
||||
// probe and the local-only config/env below don't apply (quick-start
|
||||
@@ -2147,7 +2259,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
if (!isRemote && RUN_MODE_LAUNCH[mode]) {
|
||||
const statusRes = await fetch(`/api/${mode}/status`);
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
@@ -2158,6 +2270,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, entry.unrunnableHint);
|
||||
return;
|
||||
}
|
||||
} else if (!isRemote && !this.isCliAvailable(mode)) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint);
|
||||
return;
|
||||
}
|
||||
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
@@ -2294,7 +2409,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = EXTERNAL_CLI_MODES.has(session.mode);
|
||||
const isAltMode = isExternalCliRunMode(session.mode);
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
@@ -4232,6 +4347,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Case Management (reorder + delete)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
setCaseManageFilter(value) {
|
||||
this._caseManageFilter = String(value || '');
|
||||
this.renderCaseManageList();
|
||||
},
|
||||
|
||||
renderCaseManageList() {
|
||||
const container = document.getElementById('caseManageList');
|
||||
const cases = this.cases || [];
|
||||
@@ -4240,6 +4360,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Every term must appear in the name or path (same rule as the Run picker's
|
||||
// filter). Reordering stays on the FULL list, so the arrows are disabled while
|
||||
// a filter is active: a swap with a neighbour the user cannot see is a surprise.
|
||||
const terms = (this._caseManageFilter || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const filtering = terms.length > 0;
|
||||
const visible = filtering
|
||||
? cases.filter(c => {
|
||||
const haystack = `${c.name} ${c.path || ''}`.toLowerCase();
|
||||
return terms.every(term => haystack.includes(term));
|
||||
})
|
||||
: cases;
|
||||
|
||||
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
|
||||
// A long orchestration leaves one scratch directory per worker behind, so they get
|
||||
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
|
||||
@@ -4251,9 +4383,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
|
||||
</div>`
|
||||
: '';
|
||||
cases.forEach((c, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === cases.length - 1;
|
||||
if (filtering && visible.length === 0) {
|
||||
html += '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases match</div>';
|
||||
}
|
||||
visible.forEach(c => {
|
||||
const idx = cases.indexOf(c);
|
||||
const isFirst = filtering || idx === 0;
|
||||
const isLast = filtering || idx === cases.length - 1;
|
||||
const reorderTitle = filtering ? 'Clear the search to reorder' : null;
|
||||
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
||||
// case path on a Linux host rendered in full, unabbreviated.
|
||||
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
||||
@@ -4277,9 +4414,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
: ''
|
||||
}
|
||||
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
||||
title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>▼</button>
|
||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Delete case">✕</button>
|
||||
</div>
|
||||
@@ -4452,6 +4589,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const isSelected = c.name === currentCase;
|
||||
html += `
|
||||
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
|
||||
data-search="${escapeHtml(`${c.label} ${c.name}`.toLowerCase())}"
|
||||
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
|
||||
<span class="mobile-case-item-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -4474,7 +4612,61 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
// Every open starts unfiltered. The search box is not focused on purpose:
|
||||
// that would raise the phone keyboard over a list most opens just tap.
|
||||
const search = document.getElementById('mobileCaseSearch');
|
||||
if (search) search.value = '';
|
||||
listContainer.parentElement.style.minHeight = '';
|
||||
this.filterMobileCases();
|
||||
modal.classList.add('active');
|
||||
// Bring the current case into view when the list is longer than the sheet.
|
||||
// Scroll the list's own box, never scrollIntoView(), which can also scroll
|
||||
// the document under the fixed header.
|
||||
const body = listContainer.parentElement;
|
||||
const selected = listContainer.querySelector('.mobile-case-item.selected');
|
||||
if (body && selected) {
|
||||
const top = selected.offsetTop - body.offsetTop;
|
||||
if (top + selected.offsetHeight > body.scrollTop + body.clientHeight) {
|
||||
body.scrollTop = top - (body.clientHeight - selected.offsetHeight) / 2;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Hide case rows whose name does not contain every word typed in the search box. */
|
||||
filterMobileCases() {
|
||||
const search = document.getElementById('mobileCaseSearch');
|
||||
const words = (search?.value || '').toLowerCase().split(/\s+/).filter(Boolean);
|
||||
// Hold the list at its unfiltered height while searching, so the sheet (and
|
||||
// the input under the thumb) does not jump as rows disappear.
|
||||
const body = document.querySelector('.mobile-case-picker-body');
|
||||
if (body && words.length && !body.style.minHeight) body.style.minHeight = `${body.offsetHeight}px`;
|
||||
let shown = 0;
|
||||
for (const item of document.querySelectorAll('#mobileCaseList .mobile-case-item')) {
|
||||
const hay = item.dataset.search || '';
|
||||
const match = words.every((w) => hay.includes(w));
|
||||
item.hidden = !match;
|
||||
if (match) shown++;
|
||||
}
|
||||
const empty = document.getElementById('mobileCaseEmpty');
|
||||
if (empty) empty.hidden = shown > 0;
|
||||
},
|
||||
|
||||
/** Enter picks the case when the search narrows the list to exactly one; Escape clears, then closes. */
|
||||
onMobileCaseSearchKey(event) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const visible = [...document.querySelectorAll('#mobileCaseList .mobile-case-item:not([hidden])')];
|
||||
if (visible.length === 1) visible[0].click();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.target.value) {
|
||||
event.target.value = '';
|
||||
this.filterMobileCases();
|
||||
} else {
|
||||
this.closeMobileCasePicker();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
closeMobileCasePicker() {
|
||||
@@ -4547,9 +4739,15 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
||||
return this._runMode || 'claude';
|
||||
},
|
||||
set(mode) {
|
||||
this._runMode =
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude'
|
||||
? mode
|
||||
: 'claude';
|
||||
const entry = registryCliById(mode);
|
||||
if ((entry && entry.enabled) || (!entry && BUILT_IN_RUN_MODES.has(mode))) {
|
||||
this._runMode = mode;
|
||||
return;
|
||||
}
|
||||
// A disabled (or unknown) mode falls back to the first ENABLED catalogue entry, never a
|
||||
// hardcoded 'claude': claude can be disabled too, and the server rejects a disabled mode.
|
||||
const catalog = registryCliCatalog();
|
||||
const firstEnabled = catalog.find((cli) => cli.enabled && cli.kind === 'agent') || catalog.find((cli) => cli.enabled);
|
||||
this._runMode = firstEnabled ? firstEnabled.id : 'claude';
|
||||
},
|
||||
});
|
||||
|
||||
+329
-21
@@ -410,6 +410,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Assigning .checked above does not fire onchange, so the body's visibility
|
||||
// (and its lazy load) needs an explicit sync on every open, not just a save.
|
||||
this.applyCustomModelEndpointsVisibility();
|
||||
// CLI management (docs/cli-enable-disable-plan.md): synced, default OFF.
|
||||
document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true;
|
||||
// Same reasoning as applyCustomModelEndpointsVisibility above: assigning
|
||||
// .checked fires no onchange, so the list's visibility (and lazy load)
|
||||
// needs an explicit sync on every open, not just a save.
|
||||
this.applyCliManagementVisibility();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -981,7 +987,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
desc.textContent = inert
|
||||
? 'The selected model has no 1M variant.'
|
||||
: base
|
||||
? 'Available for Fable 5.1, Fable 5, Opus and Opus 4.6.'
|
||||
? 'Available for Fable 5.1, Fable 5, Opus 5.5, Opus and Opus 4.6.'
|
||||
: 'With no model pinned, this starts new sessions on Opus with a 1M window.';
|
||||
}
|
||||
},
|
||||
@@ -1307,29 +1313,53 @@ Object.assign(CodemanApp.prototype, {
|
||||
return flags[tool] !== false;
|
||||
},
|
||||
|
||||
/** Render the registry's enabled, available CLIs as welcome-screen actions. */
|
||||
renderWelcomeCliActions() {
|
||||
const container = document.getElementById('welcomeCliActions');
|
||||
if (!container) return;
|
||||
const catalog = Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
||||
container.replaceChildren();
|
||||
for (const cli of catalog) {
|
||||
if (!cli.enabled || !this.isCliAvailable(cli.id)) continue;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `welcome-btn welcome-btn-cli welcome-btn-${cli.id}`;
|
||||
btn.dataset.mode = cli.id;
|
||||
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
icon.setAttribute('width', '20');
|
||||
icon.setAttribute('height', '20');
|
||||
icon.setAttribute('viewBox', '0 0 24 24');
|
||||
icon.setAttribute('fill', 'none');
|
||||
icon.setAttribute('stroke', 'currentColor');
|
||||
icon.setAttribute('stroke-width', '2');
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
const play = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||||
play.setAttribute('points', '5 3 19 12 5 21 5 3');
|
||||
icon.appendChild(play);
|
||||
btn.appendChild(icon);
|
||||
// Same "Run <label>" text the static buttons had ("Run Claude Code", "Run Shell"),
|
||||
// left translatable on purpose: i18n.js carries these strings, and a custom CLI's
|
||||
// label simply has no dictionary entry, so it renders as typed.
|
||||
btn.append(`Run ${cli.kind === 'shell' ? 'Shell' : cli.label}`);
|
||||
btn.onclick = () => {
|
||||
this.setRunMode(cli.id);
|
||||
void this.run();
|
||||
};
|
||||
container.appendChild(btn);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* #200: show a welcome-screen button only where the thing it launches exists.
|
||||
* The markup ships them hidden, so an old cached page can never flash a button
|
||||
* for a tool this server does not have.
|
||||
* #200: show a welcome-screen action only where the thing it launches exists.
|
||||
* The registry catalog is injected with the initial document and is updated in
|
||||
* place after a Settings toggle, so the page never offers a disabled CLI.
|
||||
*/
|
||||
applyWelcomeCliVisibility() {
|
||||
const buttons = [
|
||||
['welcomeClaudeBtn', 'claude'],
|
||||
['welcomeOpencodeBtn', 'opencode'],
|
||||
['welcomeAntigravityBtn', 'antigravity'],
|
||||
['welcomeOmpBtn', 'omp'],
|
||||
['welcomeGeminiBtn', 'gemini'],
|
||||
['welcomePiBtn', 'pi'],
|
||||
['welcomeGrokBtn', 'grok'],
|
||||
['welcomeDeepSeekBtn', 'deepseek'],
|
||||
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
|
||||
// without cloudflared can only ever produce "cloudflared not found".
|
||||
['welcomeTunnelBtn', 'cloudflared'],
|
||||
];
|
||||
for (const [id, tool] of buttons) {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none';
|
||||
}
|
||||
this.renderWelcomeCliActions();
|
||||
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
|
||||
// without cloudflared can only ever produce "cloudflared not found".
|
||||
const tunnel = document.getElementById('welcomeTunnelBtn');
|
||||
if (tunnel) tunnel.style.display = this.isCliAvailable('cloudflared') ? 'flex' : 'none';
|
||||
},
|
||||
|
||||
async loadTunnelStatus() {
|
||||
@@ -2128,6 +2158,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
|
||||
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
@@ -2723,6 +2754,282 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI management (docs/cli-enable-disable-plan.md)
|
||||
//
|
||||
// CRUD against /api/clis, rendered into the Agents & CLIs settings section.
|
||||
// Same load/save-pair-outside-openAppSettings reasoning as the Custom Model
|
||||
// Endpoints block above: these are server-side registry records, not a
|
||||
// settings-payload field — only the `cliManagementEnabled` toggle itself
|
||||
// goes through openAppSettings/saveAppSettings.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Same two-caller shape as applyCustomModelEndpointsVisibility (assigning
|
||||
* .checked fires no change event, so this needs both an explicit call on
|
||||
* open AND the checkbox's own onchange) and the same reasoning for hiding
|
||||
* the whole list rather than showing it disabled: with the flag off the
|
||||
* rows would be controls that only 403.
|
||||
*/
|
||||
applyCliManagementVisibility() {
|
||||
const enabled = document.getElementById('appSettingsCliManagement').checked;
|
||||
const group = document.getElementById('cliListGroup');
|
||||
if (group) group.style.display = enabled ? '' : 'none';
|
||||
if (enabled) this.loadCliListForSettings();
|
||||
else this.closeCliCustomForm();
|
||||
this._applyCliManagementAdminGate();
|
||||
},
|
||||
|
||||
/**
|
||||
* Decision 5 (docs/cli-enable-disable-plan.md): hidden entirely for a
|
||||
* non-admin in multi-user mode, not shown-empty. GET /api/clis already
|
||||
* answers a non-admin with [], which empties the row list on its own; the
|
||||
* "Add a custom CLI" row has no list row to hide behind, so it needs its
|
||||
* own gate the same way the Custom Model Endpoints "+ Add" button does.
|
||||
*/
|
||||
_applyCliManagementAdminGate() {
|
||||
const group = document.getElementById('cliListGroup');
|
||||
if (!group) return;
|
||||
const me = window.__codemanUser || {};
|
||||
const blocked = me.multiUser && me.role !== 'admin';
|
||||
const featureOn = document.getElementById('appSettingsCliManagement')?.checked ?? false;
|
||||
group.style.display = blocked || !featureOn ? 'none' : '';
|
||||
const addRow = document.getElementById('cliCustomAddToggle');
|
||||
if (addRow) addRow.style.display = blocked ? 'none' : '';
|
||||
},
|
||||
|
||||
async loadCliListForSettings() {
|
||||
// GET /api/clis wraps its body in the { success, data } envelope like every
|
||||
// other /api route — _apiJson() unwraps it, same reasoning as the Custom
|
||||
// Model Endpoints list load above.
|
||||
const clis = await this._apiJson('/api/clis');
|
||||
this._cliList = Array.isArray(clis) ? clis : [];
|
||||
this._syncCliLaunchCatalog();
|
||||
this.renderCliList();
|
||||
},
|
||||
|
||||
/** Keep the launch surfaces in sync with Settings mutations without a reload. */
|
||||
_syncCliLaunchCatalog() {
|
||||
if (!Array.isArray(this._cliList) || this._cliList.length === 0) return;
|
||||
window.__codemanCliCatalog = this._cliList.map((cli) => ({
|
||||
id: cli.id,
|
||||
label: cli.label,
|
||||
shortBadge: cli.shortBadge,
|
||||
order: cli.order,
|
||||
kind: cli.kind,
|
||||
enabled: cli.enabled,
|
||||
available: cli.kind === 'shell' || (cli.enabled && cli.installed),
|
||||
}));
|
||||
window.__codemanCliAvailable = {
|
||||
...(window.__codemanCliAvailable || {}),
|
||||
...Object.fromEntries(this._cliList.map((cli) => [cli.id, cli.kind === 'shell' || (cli.enabled && cli.installed)])),
|
||||
};
|
||||
if (!window.__codemanCliCatalog.some((cli) => cli.id === this.runMode && cli.enabled)) {
|
||||
this.setRunMode?.('claude');
|
||||
}
|
||||
this.applyWelcomeCliVisibility?.();
|
||||
this.renderRegistryRunOptions?.();
|
||||
this.renderMobileOverview?.();
|
||||
const menu = document.getElementById('runModeMenu');
|
||||
if (menu) this._refreshRunModeAvailability?.(menu);
|
||||
},
|
||||
|
||||
renderCliList() {
|
||||
const list = document.getElementById('cliListRows');
|
||||
if (!list) return;
|
||||
const clis = [...(this._cliList || [])].sort((a, b) => {
|
||||
// Installed CLIs first, alphabetically; then not-installed, alphabetically.
|
||||
if (a.installed !== b.installed) return a.installed ? -1 : 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
if (clis.length === 0) {
|
||||
list.innerHTML = '<p class="set-group-hint">No CLIs found.</p>';
|
||||
return;
|
||||
}
|
||||
// Mirrors cli-registry-routes.ts's own isUndisableable(): a kind 'shell' entry
|
||||
// is the one the backend refuses to ever disable (keyed on kind, never an id).
|
||||
// Revised 2026-09-23: rather
|
||||
// than render a permanently-greyed switch for it (which read as "broken"
|
||||
// next to every other row's working toggle), shell gets NO switch at all —
|
||||
// a plain "Always available" label, so there is nothing to click that
|
||||
// could look like it should work but doesn't.
|
||||
list.innerHTML = clis
|
||||
.map((c) => {
|
||||
const idArg = escapeHtml(JSON.stringify(c.id));
|
||||
const untoggleable = c.kind === 'shell';
|
||||
const installBtn =
|
||||
c.stock && !c.installed
|
||||
? `<button type="button" class="btn-toolbar btn-sm" onclick="app.installCliEntry(${idArg})" id="cliInstallBtn-${escapeHtml(c.id)}">Install</button>`
|
||||
: '';
|
||||
const customActions = c.stock
|
||||
? ''
|
||||
: `<button type="button" class="btn-toolbar btn-sm" onclick="app.openCliCustomForm(${idArg})">Edit</button>
|
||||
<button type="button" class="btn-toolbar btn-danger btn-sm" onclick="app.deleteCliCustom(${idArg})">Delete</button>`;
|
||||
const toggle = untoggleable
|
||||
? '<span class="set-row-desc">Always available</span>'
|
||||
: `<label class="switch switch-sm">
|
||||
<input type="checkbox" ${c.enabled ? 'checked' : ''} onchange="app.toggleCliEnabled(${idArg}, this)">
|
||||
<span class="slider"></span>
|
||||
</label>`;
|
||||
return `
|
||||
<div class="set-row" data-cli-id="${escapeHtml(c.id)}">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">${escapeHtml(c.label)} <span class="set-scope">${escapeHtml(c.shortBadge)}</span></span>
|
||||
<span class="set-row-desc">${c.installed ? 'Installed' : 'Not installed'}${c.stock ? '' : ' · custom'}</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
${installBtn}
|
||||
${customActions}
|
||||
${toggle}
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
/**
|
||||
* ⚠️ A successful toggle must patch `window.__codemanCliAvailable` and refresh
|
||||
* every surface that reads it, or the change is invisible everywhere except
|
||||
* this settings row until the next full page reload — `window.__codemanCliAvailable`
|
||||
* is injected ONCE at initial page render (server.ts) and nothing else refetches
|
||||
* it. Same pattern `installDeepSeekProfile()` already uses for the same reason.
|
||||
*/
|
||||
async toggleCliEnabled(id, checkbox) {
|
||||
const next = checkbox.checked;
|
||||
const res = await this._api(`/api/clis/${encodeURIComponent(id)}`, { method: 'PUT', body: { enabled: next } });
|
||||
if (!res || !res.ok) {
|
||||
checkbox.checked = !next; // revert on failure — the row must not lie about server state
|
||||
let detail = '';
|
||||
try {
|
||||
detail = (await res?.json())?.error || '';
|
||||
} catch {
|
||||
/* no body to read */
|
||||
}
|
||||
this.showToast(`Failed to ${next ? 'enable' : 'disable'} "${id}"${detail ? `: ${detail}` : ''}`, 'error');
|
||||
return;
|
||||
}
|
||||
await this.loadCliListForSettings();
|
||||
},
|
||||
|
||||
async installCliEntry(id) {
|
||||
// Installing runs a command on the server, so it never happens on a single click:
|
||||
// the confirm names the exact command POST /api/clis/:id/install would run (the
|
||||
// #343 review's "auto-install may end up behind an explicit confirm").
|
||||
const entry = (this._cliList || []).find((c) => c.id === id);
|
||||
const label = entry?.label || id;
|
||||
const command = entry?.installCommand;
|
||||
const prompt = command
|
||||
? `Install ${label}? This runs the following on the Codeman server:\n\n${command}`
|
||||
: `Install ${label}? This runs its official install command on the Codeman server.`;
|
||||
if (!confirm(prompt)) return;
|
||||
const btn = document.getElementById(`cliInstallBtn-${id}`);
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Installing…';
|
||||
}
|
||||
try {
|
||||
const res = await this._api(`/api/clis/${encodeURIComponent(id)}/install`, { method: 'POST' });
|
||||
if (!res || !res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = (await res?.json())?.error || '';
|
||||
} catch {
|
||||
/* no body to read */
|
||||
}
|
||||
this.showToast(`Installing "${id}" failed${detail ? `: ${detail}` : ''}`, 'error');
|
||||
return;
|
||||
}
|
||||
this.showToast(`Installed "${id}"`, 'success');
|
||||
} finally {
|
||||
await this.loadCliListForSettings();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pass no id to create a new entry; pass an existing CUSTOM id to edit one.
|
||||
* ⚠️ GET /api/clis deliberately excludes discovery/launch (Phase 2's own
|
||||
* scope), so an edit cannot be pre-filled with the entry's existing binary
|
||||
* or argv — those two fields start blank and must be re-entered, since the
|
||||
* update endpoint (PUT /api/clis/custom/:id) replaces the whole launch
|
||||
* spec rather than patching it. id/label/badge DO come from the list row.
|
||||
*/
|
||||
openCliCustomForm(editId) {
|
||||
const form = document.getElementById('cliCustomForm');
|
||||
const errorEl = document.getElementById('cliCustomFormError');
|
||||
if (!form) return;
|
||||
const existing = editId ? (this._cliList || []).find((c) => c.id === editId) : null;
|
||||
this._editingCliCustomId = existing ? existing.id : null;
|
||||
document.getElementById('cliCustomId').value = existing ? existing.id : '';
|
||||
document.getElementById('cliCustomId').disabled = !!existing; // id is immutable once created
|
||||
document.getElementById('cliCustomLabel').value = existing ? existing.label : '';
|
||||
document.getElementById('cliCustomBadge').value = existing ? existing.shortBadge : '';
|
||||
document.getElementById('cliCustomBinary').value = '';
|
||||
document.getElementById('cliCustomArgv').value = '';
|
||||
document.getElementById('cliCustomSubmit').textContent = existing ? 'Save' : 'Create';
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
form.style.display = '';
|
||||
},
|
||||
|
||||
closeCliCustomForm() {
|
||||
const form = document.getElementById('cliCustomForm');
|
||||
if (form) form.style.display = 'none';
|
||||
this._editingCliCustomId = null;
|
||||
},
|
||||
|
||||
/** Wired to #cliCustomForm's onsubmit; `event` is the submit event. */
|
||||
async submitCliCustomForm(event) {
|
||||
event.preventDefault();
|
||||
const errorEl = document.getElementById('cliCustomFormError');
|
||||
const showError = (msg) => {
|
||||
if (errorEl) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
};
|
||||
const id = document.getElementById('cliCustomId').value.trim();
|
||||
const label = document.getElementById('cliCustomLabel').value.trim();
|
||||
const shortBadge = document.getElementById('cliCustomBadge').value.trim();
|
||||
const binaries = document.getElementById('cliCustomBinary').value.trim().split(/\s+/).filter(Boolean);
|
||||
const argv = document.getElementById('cliCustomArgv').value.trim().split(/\s+/).filter(Boolean);
|
||||
if (!id || !label || !shortBadge || binaries.length === 0 || argv.length === 0) {
|
||||
showError('All fields are required.');
|
||||
return;
|
||||
}
|
||||
const editing = this._editingCliCustomId;
|
||||
const path = editing ? `/api/clis/custom/${encodeURIComponent(editing)}` : '/api/clis';
|
||||
const method = editing ? 'PUT' : 'POST';
|
||||
const res = await this._api(path, { method, body: { id, label, shortBadge, binaries, argv } });
|
||||
if (!res || !res.ok) {
|
||||
let detail = 'Request failed';
|
||||
try {
|
||||
detail = (await res?.json())?.error || detail;
|
||||
} catch {
|
||||
/* no body to read */
|
||||
}
|
||||
showError(detail);
|
||||
return;
|
||||
}
|
||||
this.closeCliCustomForm();
|
||||
await this.loadCliListForSettings();
|
||||
},
|
||||
|
||||
async deleteCliCustom(id) {
|
||||
const entry = (this._cliList || []).find((c) => c.id === id);
|
||||
if (!confirm(`Delete custom CLI "${entry?.label || id}"? This cannot be undone.`)) return;
|
||||
const res = await this._api(`/api/clis/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (!res || !res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = (await res?.json())?.error || '';
|
||||
} catch {
|
||||
/* no body to read */
|
||||
}
|
||||
this.showToast(`Failed to delete "${id}"${detail ? `: ${detail}` : ''}`, 'error');
|
||||
return;
|
||||
}
|
||||
await this.loadCliListForSettings();
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Visibility Settings & Device-Specific Defaults
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -3798,4 +4105,5 @@ Object.assign(CodemanApp.prototype, {
|
||||
// evaluation, not just this feature.
|
||||
document.addEventListener?.('codeman:me', () => {
|
||||
window.app?._applyCustomModelAdminGate?.();
|
||||
window.app?._applyCliManagementAdminGate?.();
|
||||
});
|
||||
|
||||
@@ -4045,6 +4045,27 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.welcome-cli-actions {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.welcome-btn-cli {
|
||||
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.welcome-btn-cli:hover {
|
||||
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
|
||||
border-color: rgba(203, 213, 225, 0.5);
|
||||
color: #f8fafc;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.run-mode-cli-options {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.welcome-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4089,6 +4110,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.welcome-btn-codex {
|
||||
background: linear-gradient(135deg, #2a0a3e 0%, #350b4d 50%, #400d5e 100%);
|
||||
border-color: rgba(168, 85, 247, 0.4);
|
||||
color: #d8b4fe;
|
||||
box-shadow: 0 2px 8px rgba(168, 85, 247, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.welcome-btn-codex:hover {
|
||||
background: linear-gradient(135deg, #400d5e 0%, #581c87 50%, #6b21a8 100%);
|
||||
box-shadow: 0 4px 20px rgba(168, 85, 247, 0.3), 0 0 40px rgba(88, 28, 135, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(192, 132, 252, 0.5);
|
||||
color: #e9d5ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Antigravity: cyan identity, matching .btn-toolbar.btn-run.mode-antigravity and
|
||||
.run-mode-dot.antigravity so the welcome action reads as the same backend. */
|
||||
.welcome-btn-antigravity {
|
||||
@@ -7036,12 +7072,61 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mobile-case-picker-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 20px 4px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.mobile-case-picker-search:focus-within {
|
||||
border-color: var(--accent, #22c55e);
|
||||
}
|
||||
|
||||
.mobile-case-picker-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
/* 16px keeps iOS Safari from zooming the page on focus */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* The wrapper draws the focus state; the global input focus ring doubled it */
|
||||
#mobileCaseSearch:focus {
|
||||
outline: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mobile-case-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mobile-case-empty[hidden],
|
||||
.mobile-case-item[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-case-picker-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 8px 0;
|
||||
max-height: 50vh;
|
||||
/* Fill the sheet (its max-height is the cap); a separate cap here left the
|
||||
list shorter than the sheet could show. min-height lets flex shrink it. */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mobile-case-list {
|
||||
@@ -16356,6 +16441,15 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
||||
}
|
||||
|
||||
/* An exited agent (Ark0N/Codeman#446): neutral, like the rich rail's exited pill.
|
||||
No green at all, since nothing is running behind this row. The dot and the row
|
||||
take the `--exited` class too and fall back to their neutral base rules. */
|
||||
.home-sessions-pill--exited {
|
||||
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--text-muted) 30%, var(--border));
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Accent, deliberately none of the three above: a session that is watching something
|
||||
it started (a monitor, a backgrounded shell, a cloud session) is not asking for
|
||||
anything, so it must not borrow the red or the yellow that mean it is. This badge
|
||||
|
||||
@@ -650,8 +650,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
|
||||
// wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local
|
||||
// scrollback otherwise. Claude Code 2.1.187+ scrolls its own
|
||||
// wheel reports (claude 2.1.187+ while it tracks the mouse, which only its
|
||||
// fullscreen renderer does; see _shouldForwardWheelToApp), local scrollback
|
||||
// otherwise. Claude Code 2.1.187+ scrolls its own
|
||||
// transcript on SGR wheel reports — scrolled-away tool blocks re-render
|
||||
// live and stay clickable — and its select menus no longer capture wheel
|
||||
// as option navigation (verified against 2.1.202: /model menu highlight
|
||||
@@ -723,7 +724,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// phone/tablet swipe scrolls the local buffer of stale repaint frames and
|
||||
// drags the CLI's pinned input box off the screen (issue #205's mobile
|
||||
// half). Same gate, so Shift has no touch analog but the local-scrollback
|
||||
// opt-out setting and the CLI-version gate apply to touch exactly as they
|
||||
// opt-out setting and the version/tracking gate apply to touch exactly as they
|
||||
// do to the wheel — including the PageUp/PageDown fallback the wheel uses
|
||||
// when that gate is false and there is no local scrollback to scroll
|
||||
// (_maybePageCliTranscript), which is what keeps a swipe from being a
|
||||
@@ -3304,9 +3305,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Post-scroll companion to _noteTerminalUserScroll: hitting the TOP of the
|
||||
* buffer while scrolling up gives the app a chance to pull the rest of tmux's
|
||||
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions decline
|
||||
* automatic pulls because their captures can be large; their banner button is
|
||||
* the explicit path. Must be called AFTER scrollLines(), since the check is on
|
||||
* scrollback (issue #205, see _maybeRefetchFullHistory). Shell sessions pull a
|
||||
* bounded window because their captures can be large; their banner button is
|
||||
* the unbounded path. Must be called AFTER scrollLines(), since the check is on
|
||||
* the resulting position, and it is deliberately not folded into
|
||||
* _noteTerminalUserScroll for exactly that reason.
|
||||
*/
|
||||
@@ -3354,13 +3355,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
* below the last line, and _estimateReplayRows can only approximate wrapping.
|
||||
* Only a capture that is worse by more than a full screen counts as a
|
||||
* downgrade, which leaves every genuine recovery case untouched.
|
||||
*
|
||||
* A caller that already estimated the capture's rows passes them as
|
||||
* `estimatedRows`, so a megabyte capture is not scanned twice.
|
||||
*/
|
||||
_replayWouldShrinkBuffer(capture) {
|
||||
_replayWouldShrinkBuffer(capture, estimatedRows) {
|
||||
const term = this.terminal;
|
||||
const rowsNow = term?.buffer?.active?.length || 0;
|
||||
if (!rowsNow) return false;
|
||||
const screen = term?.rows || 24;
|
||||
return this._estimateReplayRows(capture, term?.cols) + screen < rowsNow;
|
||||
const rows = estimatedRows ?? this._estimateReplayRows(capture, term?.cols);
|
||||
return rows + screen < rowsNow;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5082,9 +5087,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Wheel forwarding gate for the container wheel handler: no Shift override,
|
||||
// xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to
|
||||
// scroll its transcript on SGR wheel reports — which today is claude 2.1.187+
|
||||
// and nothing else (older Claude Code captures wheel as select-menu option
|
||||
// navigation; an unknown version is treated as older). Gemini and codex are
|
||||
// scroll its transcript on SGR wheel reports, which today is claude 2.1.187+
|
||||
// with mouse tracking on (fullscreen) and nothing else (older Claude Code
|
||||
// captures wheel as select-menu option navigation, an unknown version is
|
||||
// treated as older, and inline Claude ignores it). Gemini and codex are
|
||||
// strip modes too but keep the local wheel — taps/clicks are still forwarded
|
||||
// for them (harmless no-ops at worst).
|
||||
//
|
||||
@@ -5151,6 +5157,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionMode = session?.mode || 'claude';
|
||||
if (sessionMode !== 'claude') return false;
|
||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||
// Only while Claude is actually listening for the mouse. In its default
|
||||
// inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the
|
||||
// transcript lives in real scrollback, like codex, and SGR wheel reports are
|
||||
// ignored, so forwarding made every swipe and wheel tick dead. Fullscreen
|
||||
// (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json)
|
||||
// turns on alt-screen + mode 1003/1006, which the server records as
|
||||
// cliMouseTracking. A stale-false flag after a server restart falls through
|
||||
// to _maybePageCliTranscript, so it never goes dead.
|
||||
if (session?.cliMouseTracking !== true) return false;
|
||||
// Deliberately NOT gated on _terminalViewportAtBottom(). It used to be, so
|
||||
// that leaving the bottom handed the wheel back to local scrollback and both
|
||||
// histories stayed reachable without a mode switch. In practice that inverted
|
||||
@@ -5224,11 +5239,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
*
|
||||
* The rescue path for every way `_shouldForwardWheelToApp` can come back false
|
||||
* on a Claude session that has no local history to fall back on: the CLI
|
||||
* version probe failed or is genuinely older than 2.1.187, or the user turned
|
||||
* on "Wheel scrolls local history" (which pins the wheel to a buffer that,
|
||||
* for a repaint-mode CLI, is empty — the setting's footgun). Before this, all
|
||||
* of those produced a completely dead gesture; the #205 reporter proved the
|
||||
* keyboard route works by paging back through intact text with Fn+Up.
|
||||
* version probe failed or is genuinely older than 2.1.187, the CLI's mouse
|
||||
* tracking flag is unset (the inline renderer, or fullscreen right after a
|
||||
* server restart), or the user turned on "Wheel scrolls local history" (which
|
||||
* pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the
|
||||
* setting's footgun). Before this, all of those produced a completely dead
|
||||
* gesture; the #205 reporter proved the keyboard route works by paging back
|
||||
* through intact text with Fn+Up.
|
||||
*
|
||||
* Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with
|
||||
* real local scrollback is never touched. Shift is excluded on purpose: it is
|
||||
@@ -5272,14 +5289,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
const session = this.sessions?.get(sessionId);
|
||||
const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback;
|
||||
const tracking = this.terminal?.modes?.mouseTrackingMode || 'none';
|
||||
// xterm's own mode above stays 'none' for a strip mode (the server removes
|
||||
// the DECSETs), so the CLI's real tracking state is reported separately.
|
||||
const cliTracking = session?.cliMouseTracking === true;
|
||||
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
|
||||
const signature = `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${baseY > 0}`;
|
||||
const signature =
|
||||
`${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${cliTracking}|${baseY > 0}`;
|
||||
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
|
||||
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
|
||||
this._scrollRoutingLogged.set(sessionId, signature);
|
||||
console.log(
|
||||
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
|
||||
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, localScrollbackRows=${baseY})`
|
||||
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, cliMouseTracking=${cliTracking}, ` +
|
||||
`localScrollbackRows=${baseY})`
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -437,6 +437,26 @@ export function parseBody<T>(schema: z.ZodType<T>, body: unknown, errorMessage?:
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an input body is a plain prompt: printable text followed by exactly one
|
||||
* carriage return, and nothing else.
|
||||
*
|
||||
* That is the shape a script, a bot or a curl call sends to submit a prompt, and the
|
||||
* one that must NOT be written into the pane in one piece. Measured on Claude Code
|
||||
* 2.1.283 (2026-09-28): a direct write of `<text>\r` arrives as a single burst, and a
|
||||
* burst of about a hundred characters or more is taken as a paste, so its trailing
|
||||
* `\r` lands as a NEWLINE in the composer and the prompt sits there unsent. A later
|
||||
* bare `\r` written the same way does not recover it; a tmux `send-keys Enter` does.
|
||||
* Short bursts (tens of characters) submit, which is why the failure looked random.
|
||||
*
|
||||
* Anything with another control character (escape sequences, a bracketed-paste frame,
|
||||
* a line feed, a tab, C1 controls) is raw terminal input and keeps the direct write.
|
||||
*/
|
||||
export function isPlainPromptInput(input: string): boolean {
|
||||
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
||||
return /^[^\x00-\x1f\x7f-\x9f]+\r$/.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist session state and broadcast a SessionUpdated event.
|
||||
* Replaces the repeated two-line pattern across route handlers.
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* @fileoverview CLI management (docs/cli-enable-disable-plan.md) — "PR C" from the
|
||||
* original #343 review, done in phases with the trust-model scope decided up front
|
||||
* (see that doc's "Decisions" section) rather than folded into a large diff.
|
||||
*
|
||||
* Phase 2: `GET /api/clis` — read-only list, ungated (reading is cheap, not the risky part).
|
||||
* Phase 3: `PUT /api/clis/:id` — enable/disable an EXISTING entry, stock or custom; 404 for an
|
||||
* id that does not exist, so this endpoint can never become a backdoor for creating an entry
|
||||
* (that's Phase 5's job).
|
||||
* Phase 4: `POST /api/clis/:id/install` — runs a STOCK entry's already-vetted install command
|
||||
* (never a custom entry's — Decision 3). Never auto-enables; Phase 3's endpoint is still
|
||||
* the only thing that flips `enabled`.
|
||||
* Phase 5: `POST /api/clis` (create) / `PUT /api/clis/custom/:id` (update) / `DELETE
|
||||
* /api/clis/:id` (custom only) — a deliberately separate write surface from Phase 3's, so
|
||||
* "stock entries can only have `enabled` toggled, custom entries can be fully edited"
|
||||
* stays structurally true rather than depending on every caller remembering the rule.
|
||||
*
|
||||
* Every registry mutation runs through `mutateRegistryFile()` (registry-writer.ts): one at a
|
||||
* time, the existence/duplicate checks inside the same serialized step as the write, and a
|
||||
* `clis.json` that is corrupt or has unsafe permissions refused with 409 rather than
|
||||
* overwritten.
|
||||
*
|
||||
* Every write endpoint answers the SAME way when `cliManagementEnabled` is off: 403
|
||||
* FORBIDDEN with a message naming the setting, via `requireCliManagementGate()`.
|
||||
*
|
||||
* Mirrors `custom-model-routes.ts`'s shape for the closest existing precedent: same
|
||||
* admin-gating pattern, same `readXEnabled()` helper shape reading `settings.json`
|
||||
* directly rather than threading the setting through every caller, same tmp+rename+0600
|
||||
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { listClis, resolveInstallCommandForPlatform } from '../../config/cli-registry/registry.js';
|
||||
import { mutateRegistryFile, RegistryWriteRefusedError } from '../../config/cli-registry/registry-writer.js';
|
||||
import { CliEntrySchema } from '../../config/cli-registry/schema.js';
|
||||
import { STOCK_CLIS } from '../../config/cli-registry/stock.js';
|
||||
import type { CliEntry } from '../../config/cli-registry/types.js';
|
||||
import { CliCustomEntrySchema, CliEnableSchema } from '../schemas.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { invalidateCliExecutableResolvers } from '../../utils/cli-executable-resolver.js';
|
||||
import { invalidateCliResolverCache } from '../../utils/cli-resolver.js';
|
||||
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
|
||||
|
||||
/**
|
||||
* `cliManagementEnabled` defaults OFF, same reasoning as
|
||||
* `readCustomModelEndpointsEnabled` in custom-model-routes.ts: this gate gets
|
||||
* checked by every WRITE endpoint (Phases 3-5), so it needs its own reader
|
||||
* rather than threading the setting value through every route handler.
|
||||
*/
|
||||
export async function readCliManagementEnabled(): Promise<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return settings.cliManagementEnabled === true;
|
||||
}
|
||||
|
||||
export interface CliListItem {
|
||||
id: string;
|
||||
label: string;
|
||||
shortBadge: string;
|
||||
order: number;
|
||||
kind: CliEntry['kind'];
|
||||
enabled: boolean;
|
||||
stock: boolean;
|
||||
installed: boolean;
|
||||
/**
|
||||
* The command `POST /api/clis/:id/install` would run, for a STOCK entry only, so the
|
||||
* Settings UI can name it in the confirm dialog before anything executes. The same
|
||||
* display text `missingCliMessage()` already prints in "CLI not found. Install with: …";
|
||||
* absent for a custom entry, whose install command is never executed (Decision 3).
|
||||
*/
|
||||
installCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget every cached binary lookup for this CLI — the generic per-id resolver (which
|
||||
* captures the entry's binaries when first built) and every underlying per-binary cache,
|
||||
* success and negative-cache backoff alike. Called after anything that changes what is on
|
||||
* disk or what the CLI's binary IS; see `invalidateCliExecutableResolvers`.
|
||||
*/
|
||||
function forgetResolvedCli(id: string, binaries: readonly string[]): void {
|
||||
invalidateCliExecutableResolvers(binaries);
|
||||
invalidateCliResolverCache(id);
|
||||
}
|
||||
|
||||
const STOCK_IDS = new Set(STOCK_CLIS.map((e) => e.id as string));
|
||||
|
||||
/**
|
||||
* A `kind: 'shell'` entry can never be disabled — enforced here, not just in the UI (a
|
||||
* frontend-only guard is bypassable with curl). Keyed on KIND, never on an id, per the
|
||||
* registry's no-id-branching rule. Revised from Decision 4's original "shell/claude" scope
|
||||
* (2026-09-23): `claude` is now a normal toggleable entry like any other CLI. Internal
|
||||
* session creation (tmux-manager.ts, session.ts, Ralph, plan-orchestrator) resolves a CLI
|
||||
* via `getCli()`, which does NOT check `enabled` at all, so disabling `claude` only affects
|
||||
* the Run menu and the HTTP-facing `sessionModeSchema()` (new session requests via the
|
||||
* normal API) — identical in kind to disabling any other CLI, never a break to an internal
|
||||
* fallback path. The shell keeps the harder guarantee because it is the one non-agent mode
|
||||
* several code paths assume always exists as a raw-terminal fallback.
|
||||
*/
|
||||
function isUndisableable(entry: CliEntry): boolean {
|
||||
return entry.kind === 'shell';
|
||||
}
|
||||
|
||||
/** A write `mutateRegistryFile()` refused (corrupt or unsafe `clis.json`) becomes a 409 naming the fix. */
|
||||
function refusedWriteResponse(err: unknown): ApiResponse<never> {
|
||||
if (err instanceof RegistryWriteRefusedError) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every write endpoint (Phases 3-5) answers the SAME way when the feature is off or the
|
||||
* caller is a non-admin in multi-user mode: 403 FORBIDDEN. Decided once here rather than
|
||||
* per-route, per docs/cli-enable-disable-plan.md Phase 1's own checklist item ("decide
|
||||
* exact behavior... before Phase 3 starts, so all three write endpoints answer the same way").
|
||||
*/
|
||||
async function requireCliManagementGate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
if (!(await readCliManagementEnabled())) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'CLI management is disabled. Enable it in Settings first.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles a full, schema-valid `CliEntry` from Phase 5's deliberately minimal request
|
||||
* shape (id/label/shortBadge/binaries/a simple launch variant — nothing else exposed in
|
||||
* v1), filling every other required field with conservative, safe defaults: no hooks, no
|
||||
* mux-optional fallback, no privileged params, no install command (Decision 3: a custom
|
||||
* entry's install text stays display-only, and there IS none here to display), no custom
|
||||
* model injection. `CliEntrySchema` re-validates the WHOLE thing below — this function
|
||||
* only shapes the object, it is not itself the safety layer.
|
||||
*/
|
||||
function buildCustomCliEntry(
|
||||
input: { id: string; label: string; shortBadge: string; binaries: string[]; argv: string[]; enabled: boolean },
|
||||
order: number
|
||||
): unknown {
|
||||
return {
|
||||
id: input.id,
|
||||
label: input.label,
|
||||
shortBadge: input.shortBadge,
|
||||
accent: '#6b7280',
|
||||
enabled: input.enabled,
|
||||
stock: false,
|
||||
order,
|
||||
kind: 'agent',
|
||||
discovery: {
|
||||
binaries: input.binaries,
|
||||
searchDirs: [],
|
||||
install: { command: {} },
|
||||
},
|
||||
launch: {
|
||||
params: {},
|
||||
variants: [{ id: 'default', args: input.argv.map((tok) => ({ lit: tok })) }],
|
||||
},
|
||||
env: {
|
||||
exports: [],
|
||||
unset: [],
|
||||
tmuxSetenvKeys: [],
|
||||
dockerExecEnvNames: [],
|
||||
allowedPrefixes: [],
|
||||
allowedKeys: [],
|
||||
},
|
||||
capabilities: {
|
||||
external: true,
|
||||
requiresMux: true,
|
||||
hooks: 'none',
|
||||
transcript: 'none',
|
||||
altScreen: 'strip-mux-only',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'none' } },
|
||||
wheelForward: { mode: 'never' },
|
||||
keyboardAccessory: 'agent',
|
||||
privilegedCommandGate: false,
|
||||
startMode: 'interactive',
|
||||
stripInkBloat: false,
|
||||
ralph: false,
|
||||
respawn: false,
|
||||
effort: false,
|
||||
agentSkillInjection: false,
|
||||
statusLineTelemetry: false,
|
||||
model: { source: 'none' },
|
||||
privilegedParams: [],
|
||||
privilegedEnvKeys: [],
|
||||
gates: {},
|
||||
customModelInjection: { kind: 'unsupported' },
|
||||
},
|
||||
overlays: {},
|
||||
};
|
||||
}
|
||||
|
||||
function nextOrder(): number {
|
||||
const orders = listClis().map((e) => e.order);
|
||||
return (orders.length ? Math.max(...orders) : 0) + 10;
|
||||
}
|
||||
|
||||
/** Bounded execution: `PATH_INSTALL_TIMEOUT_MS`, output capped, process GROUP killed on timeout. */
|
||||
const CLI_INSTALL_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Ids with an install running right now. A second request for the same id gets 409 rather
|
||||
* than a second `curl | bash` or `npm install -g` racing the first over the same prefix.
|
||||
*/
|
||||
const installsInFlight = new Set<string>();
|
||||
|
||||
/**
|
||||
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
|
||||
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
|
||||
* data dir, the tmux socket), none of which an installer needs. Inside the Docker Compose
|
||||
* container (`CODEMAN_IN_CONTAINER=1`) it also points `NPM_CONFIG_PREFIX` at `$HOME/.local`,
|
||||
* so an `npm install -g` lands on the persistent home mount instead of the image.
|
||||
*/
|
||||
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (!key.startsWith('CODEMAN_')) env[key] = value;
|
||||
}
|
||||
// ⚠️ In the Docker Compose deployment the image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is IMAGE
|
||||
// content: `Update-Codeman.sh` recreates the container and every CLI installed there (dsh, pi, ...)
|
||||
// vanishes. HOME is the persistent bind mount and `~/.local/bin` is already on every resolver's search
|
||||
// list, so npm-based installs are redirected there. curl|bash installers already target HOME.
|
||||
if (source.CODEMAN_IN_CONTAINER === '1' && source.HOME) {
|
||||
env.NPM_CONFIG_PREFIX = `${source.HOME}/.local`;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
interface InstallResult {
|
||||
code: number | null;
|
||||
output: string;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a STOCK entry's already-vetted install command. `shell: true` is unavoidable here —
|
||||
* the shipped commands are genuinely `curl | bash` / `npm install -g` one-liners — but this
|
||||
* is NOT a reopening of the config-shell-text concern the registry's `shellToken` pattern
|
||||
* exists to prevent: the string executed here is NEVER user input, only ever what is
|
||||
* already hardcoded and reviewed in `stock.ts` (`resolveInstallCommandForPlatform`), and a
|
||||
* CUSTOM entry can never reach this function at all — see the route's own guard below.
|
||||
*/
|
||||
async function runInstallCommand(command: string): Promise<InstallResult> {
|
||||
return new Promise((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command, {
|
||||
shell: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// Own process group; the timeout below kills the whole tree by hand, mirroring
|
||||
// the DeepSeek profile-install endpoint's own reasoning: an install command fans
|
||||
// out into package-manager children, and spawn's own `timeout` option signals
|
||||
// only the direct child, leaving survivors holding the pipes open forever.
|
||||
detached: true,
|
||||
env: installEnv(),
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let output = '';
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
let reapTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const capture = (chunk: Buffer) => {
|
||||
if (output.length < 16_384) output += chunk.toString('utf-8');
|
||||
};
|
||||
child.stdout?.on('data', capture);
|
||||
child.stderr?.on('data', capture);
|
||||
|
||||
const killTree = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
if (reapTimer) clearTimeout(reapTimer);
|
||||
resolve({ code, output, timedOut });
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killTree('SIGTERM');
|
||||
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
|
||||
reapTimer = setTimeout(() => finish(null), 8_000);
|
||||
}, CLI_INSTALL_TIMEOUT_MS);
|
||||
|
||||
child.on('error', (err) => {
|
||||
output = `${output}\n${err.message}`;
|
||||
finish(null);
|
||||
});
|
||||
child.on('close', (code) => finish(code));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerCliRegistryRoutes(app: FastifyInstance): void {
|
||||
// ---- Phase 2: read ----------------------------------------------------
|
||||
// GET /api/clis — every registry entry, disabled ones included (this is an
|
||||
// admin/settings surface; every SPAWN-time caller elsewhere uses
|
||||
// enabledClis() instead). Deliberately excludes launch/env/capabilities/
|
||||
// overlays/discovery — the same rule every other catalogue-export surface in
|
||||
// this codebase follows.
|
||||
//
|
||||
// NOT gated on cliManagementEnabled: reading the list is cheap and is not
|
||||
// the risky part. The Settings UI section simply never fetches this while
|
||||
// the flag is off (Phase 6).
|
||||
app.get('/api/clis', async (req: FastifyRequest): Promise<{ success: true; data: CliListItem[] }> => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const stockAvailability = await probeStockCliAvailability();
|
||||
const data = listClis().map((entry) => ({
|
||||
id: entry.id as string,
|
||||
label: entry.label,
|
||||
shortBadge: entry.shortBadge,
|
||||
order: entry.order,
|
||||
kind: entry.kind,
|
||||
enabled: entry.enabled,
|
||||
stock: entry.stock,
|
||||
installed: isCliEntryInstalled(entry, stockAvailability),
|
||||
...(entry.stock ? { installCommand: resolveInstallCommandForPlatform(entry) } : {}),
|
||||
}));
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
// ---- Phase 3: enable/disable (stock OR custom) -------------------------
|
||||
// PUT /api/clis/:id — body { enabled }. Toggles an EXISTING entry's
|
||||
// `enabled` flag, stock or custom alike; a not-yet-existing id is 404,
|
||||
// never a backdoor into CREATING one (Phase 5 owns creation via its own
|
||||
// endpoint, POST /api/clis). This is deliberately the one simple toggle
|
||||
// both kinds of entry share — full custom-entry editing is a SEPARATE path
|
||||
// (PUT /api/clis/custom/:id) precisely so a caller can flip `enabled`
|
||||
// without first knowing the rest of a custom entry's shape (its binaries,
|
||||
// its argv), which the Settings UI list row never carries.
|
||||
app.put('/api/clis/:id', async (req, reply): Promise<ApiResponse<{ id: string; enabled: boolean }>> => {
|
||||
const denied = await requireCliManagementGate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(CliEnableSchema, req.body);
|
||||
|
||||
try {
|
||||
return await mutateRegistryFile<ApiResponse<{ id: string; enabled: boolean }>>((file) => {
|
||||
const entry = listClis().find((e) => (e.id as string) === id);
|
||||
if (!entry) {
|
||||
return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, `"${id}" does not exist`) };
|
||||
}
|
||||
if (isUndisableable(entry) && !body.enabled) {
|
||||
return { result: createErrorResponse(ApiErrorCode.INVALID_INPUT, `"${id}" cannot be disabled`) };
|
||||
}
|
||||
const existingOverride = (file.clis[id] as Record<string, unknown> | undefined) ?? {};
|
||||
file.clis = { ...file.clis, [id]: { ...existingOverride, enabled: body.enabled } };
|
||||
return { file, result: { success: true as const, data: { id, enabled: body.enabled } } };
|
||||
});
|
||||
} catch (err) {
|
||||
return refusedWriteResponse(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Phase 4: auto-install (stock only) --------------------------------
|
||||
// One install per id at a time (409 otherwise), and the script never sees CODEMAN_* env.
|
||||
// POST /api/clis/:id/install — runs the entry's already-vetted install
|
||||
// command. Separate endpoint from Phase 3's toggle: installing is a bigger
|
||||
// action than a boolean flip and gets its own audit entry. Never auto-
|
||||
// enables — Phase 3's endpoint is still the only thing that flips `enabled`.
|
||||
app.post(
|
||||
'/api/clis/:id/install',
|
||||
async (req, reply): Promise<ApiResponse<{ id: string; code: number | null; output: string }>> => {
|
||||
const denied = await requireCliManagementGate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
const { id } = req.params as { id: string };
|
||||
if (!STOCK_IDS.has(id)) {
|
||||
// Decision 3: a custom entry's install command is NEVER executed, full
|
||||
// stop — this guard is what makes that true independent of anything
|
||||
// Phase 5 does, even if a caller invents an id that happens to match
|
||||
// a custom entry's.
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Auto-install is only available for stock CLIs');
|
||||
}
|
||||
const entry = listClis().find((e) => (e.id as string) === id);
|
||||
if (!entry) return createErrorResponse(ApiErrorCode.NOT_FOUND, `"${id}" is not a stock CLI`);
|
||||
const command = resolveInstallCommandForPlatform(entry);
|
||||
if (!command) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `"${id}" has no install command for this platform`);
|
||||
}
|
||||
|
||||
if (installsInFlight.has(id)) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, `"${id}" is already being installed`);
|
||||
}
|
||||
installsInFlight.add(id);
|
||||
let result: InstallResult;
|
||||
try {
|
||||
result = await runInstallCommand(command);
|
||||
} finally {
|
||||
installsInFlight.delete(id);
|
||||
}
|
||||
// Even a failed or timed-out install may have left a binary behind, so forget the
|
||||
// cached lookups either way: the next Run click or badge read probes afresh
|
||||
// instead of replaying a pre-install miss for up to the 5-minute backoff.
|
||||
forgetResolvedCli(id, entry.discovery.binaries);
|
||||
const admin = getAuthUser(req).username;
|
||||
void appendAdminAudit({
|
||||
admin,
|
||||
action: 'cli_install',
|
||||
target: id,
|
||||
ip: req.ip,
|
||||
detail: { command, exitCode: result.code, timedOut: result.timedOut },
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
const detail = result.timedOut
|
||||
? `timed out after ${Math.round(CLI_INSTALL_TIMEOUT_MS / 1000)}s`
|
||||
: result.output.slice(-1000).trim() || 'no output';
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Installing "${id}" failed: ${detail}`);
|
||||
}
|
||||
return { success: true, data: { id, code: result.code, output: result.output.slice(-4000) } };
|
||||
}
|
||||
);
|
||||
|
||||
// ---- Phase 5: custom CLI entries ----------------------------------------
|
||||
// POST /api/clis — create a custom entry. Deliberately separate from Phase
|
||||
// 3's PUT: that endpoint can only ever toggle an EXISTING stock entry, this
|
||||
// one can only ever create a NEW custom one, so the two write surfaces
|
||||
// cannot be confused for each other by a caller.
|
||||
app.post('/api/clis', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = await requireCliManagementGate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
const body = parseBody(CliCustomEntrySchema, req.body);
|
||||
if (STOCK_IDS.has(body.id)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.ALREADY_EXISTS,
|
||||
`"${body.id}" is a stock CLI id and cannot be used for a custom entry`
|
||||
);
|
||||
}
|
||||
let outcome: ApiResponse<{ id: string }>;
|
||||
try {
|
||||
outcome = await mutateRegistryFile<ApiResponse<{ id: string }>>((file) => {
|
||||
if (Object.prototype.hasOwnProperty.call(file.clis, body.id)) {
|
||||
return {
|
||||
result: createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `A custom CLI "${body.id}" already exists`),
|
||||
};
|
||||
}
|
||||
const candidate = buildCustomCliEntry({ ...body, enabled: body.enabled ?? true }, nextOrder());
|
||||
const parsed = CliEntrySchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
return { result: createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.message) };
|
||||
}
|
||||
// Stored WITHOUT id/stock — those are forced back in by resolveRegistry() on every
|
||||
// read, so the override file never duplicates what the key and provenance already say.
|
||||
const { id: _id, stock: _stock, ...toStore } = parsed.data;
|
||||
file.clis = { ...file.clis, [body.id]: toStore };
|
||||
return { file, result: { success: true as const, data: { id: body.id } } };
|
||||
});
|
||||
} catch (err) {
|
||||
return refusedWriteResponse(err);
|
||||
}
|
||||
if (!outcome.success) return outcome;
|
||||
// A resolver may already exist for this id (a same-named entry deleted earlier in
|
||||
// this process) and would keep probing that entry's binaries.
|
||||
forgetResolvedCli(body.id, body.binaries);
|
||||
return outcome;
|
||||
});
|
||||
|
||||
// PUT /api/clis/custom/:id — full update of an EXISTING custom entry. A
|
||||
// separate path from Phase 3's PUT /api/clis/:id on purpose: that one is
|
||||
// structurally stock-only (404s any id it doesn't recognise as stock), so
|
||||
// there is no shared route where "which fields this id may change" depends
|
||||
// on a runtime check a caller could get wrong.
|
||||
app.put('/api/clis/custom/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = await requireCliManagementGate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
const { id } = req.params as { id: string };
|
||||
if (STOCK_IDS.has(id)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `"${id}" is a stock CLI; use PUT /api/clis/${id} instead`);
|
||||
}
|
||||
const body = parseBody(CliCustomEntrySchema, { ...(req.body as object), id });
|
||||
let previousBinaries: readonly string[] = [];
|
||||
let outcome: ApiResponse<{ id: string }>;
|
||||
try {
|
||||
outcome = await mutateRegistryFile<ApiResponse<{ id: string }>>((file) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(file.clis, id)) {
|
||||
return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, `No custom CLI "${id}"`) };
|
||||
}
|
||||
const existing = listClis().find((e) => (e.id as string) === id);
|
||||
previousBinaries = existing?.discovery.binaries ?? [];
|
||||
// The edit form never sends `enabled`, so an absent value keeps the entry's current
|
||||
// state: editing a disabled CLI must not quietly re-enable it.
|
||||
const enabled = body.enabled ?? existing?.enabled ?? true;
|
||||
const candidate = buildCustomCliEntry({ ...body, enabled }, existing?.order ?? nextOrder());
|
||||
const parsed = CliEntrySchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
return { result: createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.message) };
|
||||
}
|
||||
const { id: _id, stock: _stock, ...toStore } = parsed.data;
|
||||
file.clis = { ...file.clis, [id]: toStore };
|
||||
return { file, result: { success: true as const, data: { id } } };
|
||||
});
|
||||
} catch (err) {
|
||||
return refusedWriteResponse(err);
|
||||
}
|
||||
if (!outcome.success) return outcome;
|
||||
// The generic resolver captured the OLD binaries when first built; without this a
|
||||
// session spawn kept launching the previous binary until a restart.
|
||||
forgetResolvedCli(id, [...previousBinaries, ...body.binaries]);
|
||||
return outcome;
|
||||
});
|
||||
|
||||
// DELETE /api/clis/:id — refuses any STOCK id outright; deleting only ever
|
||||
// removes a CUSTOM entry's override.
|
||||
app.delete('/api/clis/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = await requireCliManagementGate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
const { id } = req.params as { id: string };
|
||||
if (STOCK_IDS.has(id)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `"${id}" is a stock CLI and cannot be deleted`);
|
||||
}
|
||||
let previousBinaries: readonly string[] = [];
|
||||
let outcome: ApiResponse<{ id: string }>;
|
||||
try {
|
||||
outcome = await mutateRegistryFile<ApiResponse<{ id: string }>>((file) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(file.clis, id)) {
|
||||
return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, `No custom CLI "${id}"`) };
|
||||
}
|
||||
previousBinaries = listClis().find((e) => (e.id as string) === id)?.discovery.binaries ?? [];
|
||||
const { [id]: _removed, ...rest } = file.clis;
|
||||
file.clis = rest;
|
||||
return { file, result: { success: true as const, data: { id } } };
|
||||
});
|
||||
} catch (err) {
|
||||
return refusedWriteResponse(err);
|
||||
}
|
||||
if (!outcome.success) return outcome;
|
||||
forgetResolvedCli(id, previousBinaries);
|
||||
return outcome;
|
||||
});
|
||||
}
|
||||
@@ -38,3 +38,4 @@ export {
|
||||
type CustomModelSessionLike,
|
||||
type CustomModelSwapDisplacement,
|
||||
} from './custom-model-routes.js';
|
||||
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
isPlainPromptInput,
|
||||
isWorkingDirAllowed,
|
||||
ownerFor,
|
||||
parseBody,
|
||||
@@ -176,6 +177,7 @@ import {
|
||||
selectLastAnsweredTurn,
|
||||
} from '../response-viewer-transcript.js';
|
||||
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
|
||||
import { appendClaudeCustomTitle } from '../../claude-session-title.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
@@ -1162,10 +1164,14 @@ export function registerSessionRoutes(
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||
// A no-op rename (the Session Options name field saves on blur and recomposes the same
|
||||
// string) must not flip nameSource to 'manual' or append a custom-title row to the transcript.
|
||||
if (name === session.name) return { name: session.name };
|
||||
session.name = name;
|
||||
// Also update the mux session name if applicable
|
||||
ctx.mux.updateSessionName(id, session.name);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
await syncClaudeTitle(session);
|
||||
return { name: session.name };
|
||||
});
|
||||
|
||||
@@ -1830,10 +1836,18 @@ export function registerSessionRoutes(
|
||||
// the wrong recovery — wait longer, when the truth is "restart the worker".
|
||||
let delivered = false;
|
||||
|
||||
// A plain prompt (`<text>\r`) goes through the mux even when the caller did not
|
||||
// ask for it: written straight into the pane it arrives as one burst, and Claude
|
||||
// Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
|
||||
// sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter
|
||||
// separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
|
||||
// raw write for a caller that really wants it.
|
||||
const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
|
||||
|
||||
if (duplicate) {
|
||||
// Redelivery of an already-applied input: skip the write, but still honor the
|
||||
// wait, since the caller's question ("tell me when this settles") is unanswered.
|
||||
} else if (useMux && waitPromise) {
|
||||
} else if ((useMux || autoMux) && waitPromise) {
|
||||
// The response is already staying open for the wait, so the tmux write can be
|
||||
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
||||
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
||||
@@ -1844,6 +1858,16 @@ export function registerSessionRoutes(
|
||||
delivered = session.write(inputStr, { fromUser: true });
|
||||
if (!delivered) undoOnFailure();
|
||||
}
|
||||
} else if (autoMux) {
|
||||
// Awaited, unlike the explicit useMux branch below. This shape also reaches here
|
||||
// from the browser's POST fallback, which sends its frames one at a time and
|
||||
// waits for each 2xx; answering only once Enter has gone out is what keeps the
|
||||
// next keystroke from overtaking it.
|
||||
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
||||
if (!ok) {
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
||||
}
|
||||
} else if (useMux) {
|
||||
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
||||
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
||||
@@ -2392,6 +2416,26 @@ export function registerSessionRoutes(
|
||||
return full ? { text: lastText, timestamp: lastTimestamp, messages } : { text: lastText, timestamp: lastTimestamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a rename into the conversation's `/resume` title (claude-session-title.ts).
|
||||
* Local Claude-format transcripts only: a remote pane's transcript lives on the remote host and a
|
||||
* docker pane's inside the container (HOME=/home/agent), never under the host's projects dir. Best
|
||||
* effort: the tab rename has already happened and must not fail on this.
|
||||
*/
|
||||
async function syncClaudeTitle(session: Session): Promise<void> {
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' || session.remote || session.docker) return;
|
||||
try {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const hookPath = ctx.getTranscriptPath(session.id);
|
||||
const transcript = hookPath
|
||||
? { sessionId: basename(hookPath, '.jsonl'), path: hookPath }
|
||||
: await findClaudeTranscript(projectsDir, session.claudeSessionId || session.id, session.id);
|
||||
if (transcript) await appendClaudeCustomTitle(transcript.path, transcript.sessionId, session.name);
|
||||
} catch (err) {
|
||||
console.warn(`[Session] Could not carry rename into the Claude transcript for ${session.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locate a top-level Claude transcript, including recovered tmux sessions. */
|
||||
async function findClaudeTranscript(
|
||||
projectsDir: string,
|
||||
|
||||
@@ -165,7 +165,11 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
|
||||
ctx.broadcast(SseEvent.WebviewChanged, {
|
||||
action: 'created',
|
||||
id: created.id,
|
||||
owner: ownerLayoutKey(created.owner),
|
||||
});
|
||||
return { success: true, data: created };
|
||||
});
|
||||
|
||||
@@ -195,7 +199,11 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
||||
// Any edit invalidates the outstanding capability. Otherwise a token minted
|
||||
// against the OLD url keeps proxying to it after the user repointed the tab.
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id });
|
||||
ctx.broadcast(SseEvent.WebviewChanged, {
|
||||
action: 'updated',
|
||||
id,
|
||||
owner: ownerLayoutKey(updated.owner),
|
||||
});
|
||||
return { success: true, data: updated };
|
||||
});
|
||||
|
||||
@@ -231,7 +239,11 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
||||
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
socketCounts.delete(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id });
|
||||
ctx.broadcast(SseEvent.WebviewChanged, {
|
||||
action: 'deleted',
|
||||
id,
|
||||
owner: ownerLayoutKey(result.owner),
|
||||
});
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
|
||||
@@ -175,7 +175,16 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
try {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) {
|
||||
// Refused for good, so say so: a silent return left the frame
|
||||
// unACKed and the client redelivered it every few seconds forever
|
||||
// (issue #484). A client that predates `err` reads this as a plain
|
||||
// ACK and drops the frame, which is also the right outcome.
|
||||
if (Number.isInteger(msg.seq) && socket.readyState === 1) {
|
||||
socket.send(`{"t":"ia","seq":${msg.seq as number},"err":"too_large","max":${MAX_INPUT_LENGTH}}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Reliable delivery: when the frame carries a clientId + seq, apply it
|
||||
// exactly once (skip a duplicate redelivery) but ACK it regardless so
|
||||
// the client can drop it from its durable queue. Frames without seq
|
||||
|
||||
+47
-1
@@ -20,6 +20,7 @@ import {
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
|
||||
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
|
||||
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
|
||||
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
|
||||
import type { SessionMode } from '../types.js';
|
||||
|
||||
@@ -1317,6 +1318,15 @@ export const SettingsUpdateSchema = z
|
||||
* discovery, and the extra toolbar surface are all opt-in.
|
||||
*/
|
||||
customModelEndpointsEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* CLI management (docs/cli-enable-disable-plan.md): the Settings UI section that
|
||||
* lets an admin enable/disable a stock CLI, trigger its install, and add/edit/
|
||||
* remove custom CLI entries — all previously hand-edit-only via ~/.codeman/clis.json.
|
||||
* SYNCED, default OFF: this is a machine-configuration surface (like Custom Model
|
||||
* Endpoints), not a display preference, and enabling it is what makes the write
|
||||
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
|
||||
*/
|
||||
cliManagementEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
||||
* default (opus: prediction quality is the product and it runs only on an
|
||||
@@ -1491,7 +1501,10 @@ export const SettingsUpdateSchema = z
|
||||
* Schema for POST /api/sessions/:id/input with length limit
|
||||
*/
|
||||
export const SessionInputWithLimitSchema = z.object({
|
||||
input: z.string().max(100000), // 100KB max input
|
||||
// One limit for both transports (issue #484): the route's own length check and
|
||||
// ws-routes.ts read the same constant, so a schema cap above it only hid which
|
||||
// check refused the input.
|
||||
input: z.string().max(MAX_INPUT_LENGTH),
|
||||
useMux: z.boolean().optional(),
|
||||
// Reliable-delivery dedup (optional; absent for curl/legacy clients). The web
|
||||
// client tags each input with a stable clientId + a monotonic per-session seq
|
||||
@@ -1997,6 +2010,39 @@ export const CustomModelHostSchema = z.object({
|
||||
modelSizesGB: z.record(z.string().max(200), z.number().positive().max(100_000)).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` —
|
||||
* duplicated rather than imported, since the REAL safety boundary for anything built from
|
||||
* this is `CliEntrySchema` itself, re-applied server-side once the full entry is assembled
|
||||
* (`cli-registry-routes.ts`). This is a request-shape sanity check, not the security gate.
|
||||
*/
|
||||
const cliShellToken = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(/^[A-Za-z0-9._:@=+/,-]+$/, 'must be a plain word with no shell metacharacters');
|
||||
|
||||
/** PUT /api/clis/:id (Phase 3) — enable/disable an existing entry, stock or custom; `enabled` is the ONLY thing this endpoint can flip. */
|
||||
export const CliEnableSchema = z.object({ enabled: z.boolean() });
|
||||
|
||||
/**
|
||||
* POST /api/clis + PUT /api/clis/custom/:id (Phase 5) — a deliberately MINIMAL custom-CLI
|
||||
* shape (docs/cli-enable-disable-plan.md, Phase 6 checklist: "scope the FIRST version to the
|
||||
* fields most stock entries actually use"), not the full `CliEntry`. `cli-registry-routes.ts`
|
||||
* assembles the rest with safe, conservative capability defaults and re-validates the whole
|
||||
* thing through `CliEntrySchema` before ever writing it — this schema exists to bound the
|
||||
* REQUEST shape, not to BE the safety layer (Decision 3: typed-argv only, no raw shell text).
|
||||
*/
|
||||
export const CliCustomEntrySchema = z.object({
|
||||
id: z.string().regex(/^[a-z][a-z0-9-]{0,23}$/, 'id must be lowercase, start with a letter, at most 24 chars'),
|
||||
label: z.string().min(1).max(60),
|
||||
shortBadge: z.string().min(1).max(6),
|
||||
enabled: z.boolean().optional(),
|
||||
binaries: z.array(cliShellToken).min(1).max(4),
|
||||
/** Bare argv tokens for the single launch variant — no flags-with-values, no params. */
|
||||
argv: z.array(cliShellToken).min(1).max(16),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/custom-model — apply or clear a session's custom-model selection. */
|
||||
export const CustomModelSelectionSchema = z.union([
|
||||
z.object({
|
||||
|
||||
+38
-2
@@ -226,6 +226,29 @@ export function reconcileStatusDecision(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE runtime staleness check, applied whenever the status is READ (not only on
|
||||
* boot). The live updater heartbeats `updatedAt` every few seconds, so an
|
||||
* in-flight status whose last write is older than the window has no updater
|
||||
* behind it. Without this, a status that never advanced (e.g. the updater could
|
||||
* not run its `--node` binary because Homebrew upgraded node under a long-running
|
||||
* server, so every status write failed) blocked every later update with "An
|
||||
* update is already in progress." until the server happened to restart.
|
||||
* Returns the failed status to persist, or null to leave the status untouched.
|
||||
*/
|
||||
export function expireStalledStatus(status: UpdateStatus | null, now: number): UpdateStatus | null {
|
||||
if (!status || !IN_FLIGHT_PHASES.has(status.phase)) return null;
|
||||
const lastWrite = status.updatedAt || status.startedAt;
|
||||
if (now - lastWrite <= RECONCILE_STALE_MS) return null;
|
||||
return {
|
||||
...status,
|
||||
phase: 'failed',
|
||||
message: 'Update stopped reporting progress',
|
||||
error: `no status update for ${Math.round((now - lastWrite) / 60_000)} min during "${status.phase}"`,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PURE helpers — the container environment gate
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -380,6 +403,19 @@ export function writeUpdateStatusAtomic(status: UpdateStatus): void {
|
||||
renameSync(tmp, STATUS_FILE);
|
||||
}
|
||||
|
||||
/** Read the status, first failing (and persisting) an in-flight one that stopped heartbeating. */
|
||||
function readCurrentUpdateStatus(now = Date.now()): UpdateStatus | null {
|
||||
const status = readUpdateStatus();
|
||||
const expired = expireStalledStatus(status, now);
|
||||
if (!expired) return status;
|
||||
try {
|
||||
writeUpdateStatusAtomic(expired);
|
||||
} catch {
|
||||
// Still report the expired view; the next read retries the write.
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
/** Reconcile the status file on server boot (call once, early in start()). */
|
||||
export function reconcileUpdateOnBoot(now = Date.now()): void {
|
||||
const status = readUpdateStatus();
|
||||
@@ -796,7 +832,7 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
};
|
||||
}
|
||||
const existing = readUpdateStatus();
|
||||
const existing = readCurrentUpdateStatus();
|
||||
if (isInFlight(existing)) {
|
||||
return { ok: false, code: 'in-flight', message: 'An update is already in progress.' };
|
||||
}
|
||||
@@ -885,7 +921,7 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
|
||||
/** Current status for the polling endpoint; null collapses to an explicit idle. */
|
||||
export function getUpdateStatusForApi(): UpdateStatus {
|
||||
const status = readUpdateStatus();
|
||||
const status = readCurrentUpdateStatus();
|
||||
if (status) return status;
|
||||
return {
|
||||
updateId: '',
|
||||
|
||||
+151
-44
@@ -33,7 +33,8 @@ import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { startPasteImageGc } from './paste-image-gc.js';
|
||||
import { pasteImageDirInUseByOtherSession, startPasteImageGc } from './paste-image-gc.js';
|
||||
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
|
||||
@@ -71,7 +72,8 @@ import {
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import { getCli, enabledClis } from '../config/cli-registry/registry.js';
|
||||
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
|
||||
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
|
||||
import { readCustomModelHosts } from '../custom-model-hosts.js';
|
||||
import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js';
|
||||
import type { CustomModelBookkeeping } from '../types/session.js';
|
||||
@@ -94,6 +96,7 @@ import { PushSubscriptionStore } from '../push-store.js';
|
||||
import webpush from 'web-push';
|
||||
import { SseStreamManager } from './sse-stream-manager.js';
|
||||
import { deriveTabLayoutSseHint } from './tab-layout-sse.js';
|
||||
import { deriveWebviewSseHint } from './webview-sse.js';
|
||||
import {
|
||||
type SessionListenerRefs,
|
||||
createSessionListeners,
|
||||
@@ -201,6 +204,7 @@ import {
|
||||
detectCustomModelSwapDisplacements,
|
||||
pruneIdleLlamaSwapLogTails,
|
||||
tryWebviewRefererFallback,
|
||||
registerCliRegistryRoutes,
|
||||
} from './routes/index.js';
|
||||
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
@@ -1127,6 +1131,7 @@ export class WebServer extends EventEmitter {
|
||||
registerWebviewRoutes(this.app, ctx, this.basePath);
|
||||
registerTabLayoutRoutes(this.app, ctx);
|
||||
registerCustomModelRoutes(this.app);
|
||||
registerCliRegistryRoutes(this.app);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
// due times for any persisted jobs, then expose it to its routes.
|
||||
@@ -1318,16 +1323,32 @@ export class WebServer extends EventEmitter {
|
||||
// Clean up all resources associated with a session
|
||||
// Track sessions currently being cleaned up to prevent concurrent cleanup races
|
||||
private cleaningUp: Set<string> = new Set();
|
||||
/**
|
||||
* The subset of {@link cleaningUp} whose tmux session is being KILLED rather
|
||||
* than detached. The paste-image guard needs the difference: a detaching
|
||||
* session keeps running in tmux and still uses its working directory.
|
||||
*/
|
||||
private killingSessions: Set<string> = new Set();
|
||||
|
||||
private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> {
|
||||
// Guard against concurrent cleanup of the same session
|
||||
if (this.cleaningUp.has(sessionId)) return;
|
||||
this.cleaningUp.add(sessionId);
|
||||
if (killMux) this.killingSessions.add(sessionId);
|
||||
// Refuse a start or attach from here on (Ark0N/Codeman#446): a start that
|
||||
// raced this cleanup would launch a CLI in a tmux session whose record is
|
||||
// about to be deleted, leaving an orphan the next boot rediscovers.
|
||||
const session = this.sessions.get(sessionId);
|
||||
session?.markClosing(true);
|
||||
|
||||
try {
|
||||
await this._doCleanupSession(sessionId, killMux, reason);
|
||||
} finally {
|
||||
this.cleaningUp.delete(sessionId);
|
||||
this.killingSessions.delete(sessionId);
|
||||
// A cleanup that failed leaves the session on the board, so it must be
|
||||
// startable again.
|
||||
if (this.sessions.get(sessionId) === session) session?.markClosing(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1479,8 +1500,24 @@ export class WebServer extends EventEmitter {
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
// Stop watching for images in this session's directory
|
||||
imageWatcher.unwatchSession(sessionId);
|
||||
// Clean up pasted images directory for this session
|
||||
if (killMux && session.workingDir) {
|
||||
// Clean up pasted images directory for this session. The dir belongs to the
|
||||
// working directory rather than the session, so it stays while another live
|
||||
// session in the same case still uses it (Ark0N/Codeman#446).
|
||||
if (
|
||||
killMux &&
|
||||
session.workingDir &&
|
||||
!pasteImageDirInUseByOtherSession({
|
||||
live: this.sessions.values(),
|
||||
persisted: Object.entries(this.store.getSessions()).map(([id, record]) => ({
|
||||
id,
|
||||
workingDir: record.workingDir,
|
||||
status: record.status,
|
||||
})),
|
||||
closingId: sessionId,
|
||||
workingDir: session.workingDir,
|
||||
killing: this.killingSessions,
|
||||
})
|
||||
) {
|
||||
const pasteImageDir = join(session.workingDir, '.claude-images');
|
||||
try {
|
||||
rmSync(pasteImageDir, { recursive: true, force: true });
|
||||
@@ -1620,56 +1657,59 @@ export class WebServer extends EventEmitter {
|
||||
//
|
||||
// Solo popups skip it: no settings modal, no welcome screen, no run menu.
|
||||
if (!soloSessionId) {
|
||||
const [
|
||||
{ isClaudeAvailable },
|
||||
{ isOpenCodeAvailable },
|
||||
{ isCodexAvailable },
|
||||
{ isGeminiAvailable },
|
||||
{ isAntigravityAvailable },
|
||||
{ isPiAvailable },
|
||||
{ isGrokAvailable },
|
||||
{ isDeepSeekRunnable, isDeepSeekAvailable },
|
||||
{ isOmpAvailable },
|
||||
{ isCloudflaredAvailable },
|
||||
{ isGitAvailable },
|
||||
] = await Promise.all([
|
||||
import('../utils/claude-cli-resolver.js'),
|
||||
import('../utils/opencode-cli-resolver.js'),
|
||||
import('../utils/codex-cli-resolver.js'),
|
||||
import('../utils/gemini-cli-resolver.js'),
|
||||
import('../utils/antigravity-cli-resolver.js'),
|
||||
import('../utils/pi-cli-resolver.js'),
|
||||
import('../utils/grok-cli-resolver.js'),
|
||||
import('../utils/deepseek-cli-resolver.js'),
|
||||
import('../utils/omp-cli-resolver.js'),
|
||||
import('../utils/cloudflared-resolver.js'),
|
||||
import('../git-clone.js'),
|
||||
]);
|
||||
const available = {
|
||||
claude: isClaudeAvailable(),
|
||||
opencode: isOpenCodeAvailable(),
|
||||
codex: isCodexAvailable(),
|
||||
gemini: isGeminiAvailable(),
|
||||
antigravity: isAntigravityAvailable(),
|
||||
pi: isPiAvailable(),
|
||||
grok: isGrokAvailable(),
|
||||
// RUNNABLE, not merely installed: `dsh` is a profile launcher, and a dsh
|
||||
// with no pane-capable profile would offer a Run button that spawns a
|
||||
// pane which dies on arrival. The Add-Profile affordance in the run menu
|
||||
// keys off `deepseekBinary` instead, so a user who has the binary but no
|
||||
// profile is offered the fix rather than a greyed-out entry.
|
||||
deepseek: isDeepSeekRunnable(),
|
||||
const [{ isDeepSeekAvailable }, { isCloudflaredAvailable }, { isGitAvailable }, stockAvailability] =
|
||||
await Promise.all([
|
||||
import('../utils/deepseek-cli-resolver.js'),
|
||||
import('../utils/cloudflared-resolver.js'),
|
||||
import('../git-clone.js'),
|
||||
// Shared with GET /api/clis so the Settings badge and the Run menu cannot disagree.
|
||||
probeStockCliAvailability(),
|
||||
]);
|
||||
const available: Record<string, boolean> = {
|
||||
...stockAvailability,
|
||||
// `deepseek` above is RUNNABLE (binary + a pane-capable profile). The Add-Profile
|
||||
// affordance in the run menu keys off `deepseekBinary` instead, so a user who has
|
||||
// the binary but no profile is offered the fix rather than a greyed-out entry.
|
||||
deepseekBinary: isDeepSeekAvailable(),
|
||||
omp: isOmpAvailable(),
|
||||
cloudflared: isCloudflaredAvailable(),
|
||||
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
|
||||
// keep without git (issue #236), same reasoning as cloudflared above.
|
||||
git: isGitAvailable(),
|
||||
};
|
||||
// A CLI disabled via the registry (docs/cli-enable-disable-plan.md's Settings UI,
|
||||
// or a hand-edited clis.json) must read as unavailable here too — `isCliAvailable()`
|
||||
// on the frontend is what the welcome screen, the Run-menu dropdown and the mobile
|
||||
// overview all gate on, and none of them otherwise know the registry's `enabled`
|
||||
// flag exists; without this, disabling a CLI in Settings toggled the row there but
|
||||
// left every launch surface still offering it. `git`/`cloudflared` are utility
|
||||
// binaries, not CLI registry entries, and `deepseekBinary` is a secondary
|
||||
// installed-only flag for the "add a profile" affordance — none of the three are
|
||||
// registry ids, so only the nine real SessionMode entries are gated.
|
||||
const cliCatalog = listClis().map((entry) => {
|
||||
const id = entry.id as string;
|
||||
const installed = isCliEntryInstalled(entry, stockAvailability);
|
||||
const enabled = entry.enabled;
|
||||
available[id] = enabled && installed;
|
||||
return {
|
||||
id,
|
||||
label: entry.label,
|
||||
shortBadge: entry.shortBadge,
|
||||
order: entry.order,
|
||||
kind: entry.kind,
|
||||
enabled,
|
||||
available: enabled && installed,
|
||||
};
|
||||
});
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
() => `<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
|
||||
);
|
||||
// The launch surfaces consume this deliberately small projection rather than
|
||||
// carrying a second hand-maintained list of CLI ids. It includes disabled
|
||||
// entries so Settings can redraw immediately after a toggle, while each
|
||||
// renderer filters on `enabled`/`available` before offering a launch action.
|
||||
const cliCatalogJson = escapeScriptJson(JSON.stringify(cliCatalog));
|
||||
html = html.replace('</head>', () => `<script>window.__codemanCliCatalog=${cliCatalogJson};</script>\n</head>`);
|
||||
// Which run modes the Run-menu picker (docs/custom-model-endpoints-plan.md) may
|
||||
// generate an entry for: read generically off the registry's `capabilities`
|
||||
// (never an id list here) so a CLI whose customModelInjection lands later shows
|
||||
@@ -2422,6 +2462,12 @@ export class WebServer extends EventEmitter {
|
||||
if (event.startsWith('tab:')) {
|
||||
return deriveTabLayoutSseHint(data);
|
||||
}
|
||||
// Saved-webview invalidations carry the trusted resource owner. Route them to
|
||||
// that owner (plus admins), so an admin editing a user's web tab notifies the
|
||||
// user, and no other user learns the ids of someone else's web tabs.
|
||||
if (event.startsWith('webview:')) {
|
||||
return deriveWebviewSseHint(data);
|
||||
}
|
||||
// Session-scoped families: resolve the owner from the payload's session id.
|
||||
const SESSION_PREFIXES = [
|
||||
'session:',
|
||||
@@ -2496,6 +2542,9 @@ export class WebServer extends EventEmitter {
|
||||
* Nothing here touches `status` or `pid`. `status: 'error'` belongs to the
|
||||
* PTY-exit breaker and makes the browser offer a restart, and a null `pid` is
|
||||
* what makes the browser re-attach and launch a fresh CLI.
|
||||
*
|
||||
* Once the records are current, {@link closeCleanlyExitedSessions} closes the
|
||||
* sessions whose agent the user ended.
|
||||
*/
|
||||
private applyPaneExits(): void {
|
||||
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
|
||||
@@ -2509,6 +2558,63 @@ export class WebServer extends EventEmitter {
|
||||
this.persistSessionState(session);
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
}
|
||||
this.closeCleanlyExitedSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every session whose agent exited cleanly, through the same
|
||||
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
|
||||
* is demoted to `status: 'stopped'` there rather than removed, and either way
|
||||
* the reboot restore stops offering it back. The conversation stays
|
||||
* resumable, since the Resume list reads the lifecycle log and the transcript
|
||||
* files, and the pane owns neither.
|
||||
*
|
||||
* `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`) holds the rule:
|
||||
* an explicit status of 0, confirmed by more than one pane read, with no
|
||||
* start or attach in flight and not within seconds of one (a startup error). A crashed agent keeps its row with the exit
|
||||
* code on it. `session.paneExit` is already scoped to local mux-backed
|
||||
* sessions by `setPaneExit()`, so a remote, docker or direct-PTY session is
|
||||
* never closed here.
|
||||
*
|
||||
* The close runs in the background. `cleanupSession()` ignores a second call
|
||||
* for a session it is already closing, and the `closing` check below keeps
|
||||
* the next tick from queueing one.
|
||||
*
|
||||
* Each exit is attempted ONCE, keyed by session id and the exit's `at`
|
||||
* stamp. A close that fails leaves the session on the board with its exit
|
||||
* badge, which is where a crashed agent's row would be too, rather than
|
||||
* retrying and logging every two seconds. A new exit in the same pane has a
|
||||
* new `at` and gets its own attempt.
|
||||
*/
|
||||
/** Exits the clean-exit sweep has already tried to close, as `<sessionId>:<exit.at>`. */
|
||||
private cleanExitCloseAttempts: Set<string> = new Set();
|
||||
|
||||
private closeCleanlyExitedSessions(): void {
|
||||
const readCount = this.mux.getPaneExitReadCount?.bind(this.mux);
|
||||
if (!readCount) return;
|
||||
// Forget attempts for sessions that are gone, so the set stays bounded.
|
||||
for (const key of this.cleanExitCloseAttempts) {
|
||||
if (!this.sessions.has(key.slice(0, key.lastIndexOf(':')))) this.cleanExitCloseAttempts.delete(key);
|
||||
}
|
||||
for (const session of [...this.sessions.values()]) {
|
||||
const muxName = session.muxName;
|
||||
if (!muxName) continue;
|
||||
const close = shouldCloseCleanlyExitedSession({
|
||||
paneExit: session.paneExit,
|
||||
confirmingReads: readCount(muxName),
|
||||
paneLifecycleInFlight: session.paneLifecycleInFlight,
|
||||
closing: this.cleaningUp.has(session.id),
|
||||
paneStartedAt: session.paneStartedAt,
|
||||
});
|
||||
if (!close) continue;
|
||||
const attempt = `${session.id}:${session.paneExit?.at ?? 0}`;
|
||||
if (this.cleanExitCloseAttempts.has(attempt)) continue;
|
||||
this.cleanExitCloseAttempts.add(attempt);
|
||||
console.log(`[Server] Closing session ${session.id} (${session.name}): ${CLEAN_EXIT_CLOSE_REASON}`);
|
||||
void this.cleanupSession(session.id, true, CLEAN_EXIT_CLOSE_REASON).catch((err) => {
|
||||
console.error(`[Server] Failed to close cleanly exited session ${session.id}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Web Push ==========
|
||||
@@ -3957,6 +4063,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
this.activePlanOrchestrators.clear();
|
||||
this.cleaningUp.clear();
|
||||
this.killingSessions.clear();
|
||||
|
||||
// Dispose push store (flush pending saves)
|
||||
this.pushStore.dispose();
|
||||
|
||||
@@ -481,8 +481,10 @@ export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const
|
||||
export const SessionOrderChanged = 'session:orderChanged' as const;
|
||||
|
||||
/** A saved web tab (dashboard URL) was created, updated or deleted.
|
||||
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
|
||||
* re-fetches the list rather than patching from the payload. */
|
||||
* Payload: `{ action: 'created' | 'updated' | 'deleted', id, owner }`. The client
|
||||
* re-fetches the list rather than patching from the payload. `owner` is the web
|
||||
* tab's owner (`'@single'` when multi-user mode is off); in multi-user mode the
|
||||
* event is delivered only to that owner and admins (`deriveWebviewSseHint`). */
|
||||
export const WebviewChanged = 'webview:changed' as const;
|
||||
/** Owner-scoped layout invalidation. Payload contains only `{ owner, version }`. */
|
||||
export const TabLayoutChanged = 'tab:layoutChanged' as const;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @fileoverview Trusted owner routing metadata for saved-webview invalidations. */
|
||||
import type { SseRoutingHint } from './sse-stream-manager.js';
|
||||
|
||||
export function deriveWebviewSseHint(data: unknown): SseRoutingHint {
|
||||
return { username: (data as { owner?: string }).owner, sessionScoped: true };
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
agentImageNpmPackages as mjsPackages,
|
||||
GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs,
|
||||
gitHostCliBuildArgPairs as mjsGitHostPairs,
|
||||
GIT_IDENTITY_BUILD_ARGS as mjsGitIdentityArgs,
|
||||
gitIdentityBuildArgPairs as mjsGitIdentityPairs,
|
||||
} from '../scripts/lib/cli-catalog.mjs';
|
||||
import {
|
||||
agentImageBuildArgPairs as tsPairs,
|
||||
@@ -29,6 +31,8 @@ import {
|
||||
agentImageNpmPackages as tsPackages,
|
||||
GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs,
|
||||
gitHostCliBuildArgPairs as tsGitHostPairs,
|
||||
GIT_IDENTITY_BUILD_ARGS as tsGitIdentityArgs,
|
||||
gitIdentityBuildArgPairs as tsGitIdentityPairs,
|
||||
} from '../src/docker-hosts.js';
|
||||
|
||||
const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8'));
|
||||
@@ -145,3 +149,52 @@ describe('optional gh / az in the agent image: both producers pass the same swit
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git identity in the agent image: both producers pass the same settings', () => {
|
||||
it('maps the Git environment variables to matching Dockerfile ARGs', () => {
|
||||
expect(tsGitIdentityArgs).toEqual(mjsGitIdentityArgs);
|
||||
expect(tsGitIdentityArgs).toEqual([
|
||||
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
|
||||
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes a complete identity and omits an absent identity', () => {
|
||||
const identity = {
|
||||
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: 'Ada Lovelace',
|
||||
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: 'ada@example.com',
|
||||
};
|
||||
const expected: Array<[string, string]> = [
|
||||
['GIT_USER_NAME', 'Ada Lovelace'],
|
||||
['GIT_USER_EMAIL', 'ada@example.com'],
|
||||
];
|
||||
expect(tsGitIdentityPairs(identity)).toEqual(expected);
|
||||
expect(mjsGitIdentityPairs(identity)).toEqual(expected);
|
||||
expect(tsGitIdentityPairs({})).toEqual([]);
|
||||
expect(mjsGitIdentityPairs({})).toEqual([]);
|
||||
// The combined argv, not just the helper: the manual build path could drop the identity otherwise.
|
||||
expect(tsPairs(identity)).toEqual(mjsPairs(CATALOG, identity));
|
||||
expect(tsPairs(identity)).toEqual(expect.arrayContaining(expected));
|
||||
});
|
||||
|
||||
it('refuses a partial identity in both build paths', () => {
|
||||
for (const identity of [
|
||||
{ CODEMAN_AGENT_IMAGE_GIT_USER_NAME: 'Ada Lovelace' },
|
||||
{ CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: 'ada@example.com' },
|
||||
]) {
|
||||
const named = /CODEMAN_AGENT_IMAGE_GIT_USER_NAME and CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL must both be set/;
|
||||
expect(() => tsGitIdentityPairs(identity)).toThrow(named);
|
||||
expect(() => mjsGitIdentityPairs(identity)).toThrow(named);
|
||||
}
|
||||
});
|
||||
|
||||
it('both Dockerfiles configure system Git identity from the build arguments', () => {
|
||||
for (const file of ['../docker/agent.Dockerfile', '../docker/server.Dockerfile']) {
|
||||
const dockerfile = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf-8');
|
||||
expect(dockerfile, file).toMatch(/^ARG GIT_USER_NAME=$/m);
|
||||
expect(dockerfile, file).toMatch(/^ARG GIT_USER_EMAIL=$/m);
|
||||
expect(dockerfile, file).toContain('git config --system user.name "${GIT_USER_NAME}"');
|
||||
expect(dockerfile, file).toContain('git config --system user.email "${GIT_USER_EMAIL}"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,13 @@ describe('App Settings modal structure', () => {
|
||||
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
|
||||
// The cards render the base models; the [1m] rows exist so that base + the
|
||||
// context switch can compose back into a real claudeModel value.
|
||||
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-fable-5-1[1m]', 'claude-opus-4-6[1m]']) {
|
||||
for (const value of [
|
||||
'opus[1m]',
|
||||
'claude-fable-5[1m]',
|
||||
'claude-fable-5-1[1m]',
|
||||
'claude-opus-5-5[1m]',
|
||||
'claude-opus-4-6[1m]',
|
||||
]) {
|
||||
expect(select).toContain(`value="${value}"`);
|
||||
}
|
||||
expect(select).toContain('data-ctx="1"');
|
||||
@@ -148,6 +154,22 @@ describe('App Settings modal structure', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('models: offers Opus 5.5 as a card and to task routing', () => {
|
||||
const modal = settingsModal();
|
||||
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
|
||||
expect(select).toMatch(/value="claude-opus-5-5"[^>]*data-ctx="1"/);
|
||||
for (const id of [
|
||||
'appSettingsDefaultModel',
|
||||
'appSettingsModelExplore',
|
||||
'appSettingsModelImplement',
|
||||
'appSettingsModelTest',
|
||||
'appSettingsModelReview',
|
||||
]) {
|
||||
const routing = modal.match(new RegExp(`id="${id}"([\\s\\S]*?)</select>`))?.[1] ?? '';
|
||||
expect(routing, `${id} does not offer Opus 5.5`).toContain('value="claude-opus-5-5"');
|
||||
}
|
||||
});
|
||||
|
||||
it('has retired the modal-tab chrome everywhere, not just here', () => {
|
||||
// Session Options and Add Case moved onto this same `set-*` surface, so the
|
||||
// old tab classes have no users left. A reappearance means a modal drifted
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @fileoverview scripts/check-browser-test-excludes.mjs: the detection side (which test
|
||||
* files need a real browser) and the leak computation. The exclusion side is vitest's own
|
||||
* `vitest list`, which `npm run check:browser-excludes` exercises for real in CI.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
findBrowserTests,
|
||||
findLeaks,
|
||||
findTestFiles,
|
||||
importsBrowserDriver,
|
||||
listingMatchesTree,
|
||||
parseVitestFileList,
|
||||
} from '../scripts/check-browser-test-excludes.mjs';
|
||||
import { BROWSER_TEST_GLOBS } from '../config/test-suites';
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..');
|
||||
|
||||
// Fixture sources are assembled from the module name at runtime, so THIS file never contains
|
||||
// a literal driver import and is not itself flagged by the checker it tests.
|
||||
const fromImport = (mod: string) => `import { chromium, type Browser } from '${mod}';\n`;
|
||||
|
||||
describe('importsBrowserDriver', () => {
|
||||
it.each([
|
||||
fromImport('playwright'),
|
||||
fromImport('playwright-core').replace(/'/g, '"'),
|
||||
fromImport('@playwright/test'),
|
||||
fromImport('puppeteer'),
|
||||
`import type { Page } from '${'playwright'}';`,
|
||||
`const { chromium } = require('${'playwright'}');`,
|
||||
`const pw = await import('${'playwright'}');`,
|
||||
])('flags %s', (src) => {
|
||||
expect(importsBrowserDriver(src)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"import { describe } from 'vitest';",
|
||||
"// needs ms-playwright's cache dir\nconst dir = '.cache/ms-playwright';",
|
||||
fromImport('./playwright-helpers'),
|
||||
fromImport('playwright-extra-thing'),
|
||||
])('ignores %s', (src) => {
|
||||
expect(importsBrowserDriver(src)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBrowserTests (fixture tree)', () => {
|
||||
let root: string;
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'codeman-browser-excludes-'));
|
||||
const put = (rel: string, src: string) => {
|
||||
mkdirSync(join(root, rel, '..'), { recursive: true });
|
||||
writeFileSync(join(root, rel), src);
|
||||
};
|
||||
put('test/unit.test.ts', "import { it } from 'vitest';\n");
|
||||
put('test/legacy-name.test.ts', fromImport('playwright'));
|
||||
put('test/new.browser.test.ts', fromImport('playwright'));
|
||||
put('test/nested/deep.test.ts', fromImport('puppeteer'));
|
||||
put('test/helpers/browser.ts', fromImport('playwright')); // not a test file
|
||||
});
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('finds driver imports by content, recursively, as sorted repo-relative paths', () => {
|
||||
expect(findBrowserTests(root)).toEqual([
|
||||
'test/legacy-name.test.ts',
|
||||
'test/nested/deep.test.ts',
|
||||
'test/new.browser.test.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists every test file, browser-driven or not, in the same form', () => {
|
||||
expect(findTestFiles(root)).toEqual([
|
||||
'test/legacy-name.test.ts',
|
||||
'test/nested/deep.test.ts',
|
||||
'test/new.browser.test.ts',
|
||||
'test/unit.test.ts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVitestFileList + findLeaks', () => {
|
||||
it('keeps only test paths and normalizes a leading ./', () => {
|
||||
const out = '\n./test/a.test.ts\ntest/b.test.ts\nsome banner line\n test/c.test.ts \n';
|
||||
expect([...parseVitestFileList(out)].sort()).toEqual(['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts']);
|
||||
});
|
||||
|
||||
it('reports exactly the browser tests the CI set still collects', () => {
|
||||
const ci = new Set(['test/unit.test.ts', 'test/legacy-name.test.ts']);
|
||||
expect(findLeaks(['test/legacy-name.test.ts', 'test/new.browser.test.ts'], ci)).toEqual([
|
||||
'test/legacy-name.test.ts',
|
||||
]);
|
||||
expect(findLeaks(['test/new.browser.test.ts'], ci)).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a non-empty listing whose paths never match the tree instead of passing vacuously', () => {
|
||||
const tree = ['test/legacy-name.test.ts', 'test/unit.test.ts'];
|
||||
// e.g. a vitest upgrade that starts printing absolute paths: nothing leaks, but only
|
||||
// because nothing matches, so the checker must refuse rather than report success.
|
||||
const drifted = parseVitestFileList('/repo/test/legacy-name.test.ts\n/repo/test/unit.test.ts\n');
|
||||
expect(drifted.size).toBe(2);
|
||||
expect(findLeaks(['test/legacy-name.test.ts'], drifted)).toEqual([]);
|
||||
expect(listingMatchesTree(drifted, tree)).toBe(false);
|
||||
|
||||
const healthy = parseVitestFileList('test/legacy-name.test.ts\ntest/unit.test.ts\n');
|
||||
expect(listingMatchesTree(healthy, tree)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('against this repository', () => {
|
||||
it('detects every file already listed in BROWSER_TEST_GLOBS', () => {
|
||||
// If detection stopped recognising a known browser test, the checker would go blind to
|
||||
// exactly the class of file it exists for.
|
||||
const detected = new Set(findBrowserTests(repoRoot));
|
||||
const literals = BROWSER_TEST_GLOBS.filter((g) => !/[*?[{]/.test(g));
|
||||
expect(literals.length).toBeGreaterThan(0);
|
||||
for (const file of literals) expect(detected, file).toContain(file);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @fileoverview Claude's `/resume` title belongs to Claude unless the user chose one.
|
||||
*
|
||||
* `--name` sets the prompt-box label, the `/resume` picker entry and the terminal
|
||||
* title, and a pinned title stops Claude generating its own. Pinning the
|
||||
* `w1-myapp` placeholder therefore listed every conversation of a case under the
|
||||
* same name in `/resume`. Only a manual name is pinned now (`cliPinnedName`), and
|
||||
* a rename reaches the transcript as a `custom-title` row.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { appendClaudeCustomTitle } from '../src/claude-session-title.js';
|
||||
|
||||
type RespawnOptionsProbe = { _buildRespawnPaneOptions(): { name?: string; cliName?: string } };
|
||||
|
||||
describe('Session.cliPinnedName', () => {
|
||||
it('pins nothing for a placeholder, so Claude titles the conversation itself', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.cliPinnedName).toBeUndefined();
|
||||
const options = (session as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions();
|
||||
// The tab keeps its name; only the CLI flag is withheld.
|
||||
expect(options.name).toBe('w1-demo');
|
||||
expect(options.cliName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins nothing for an auto name, whose cut of the prompt Claude beats', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
|
||||
expect(session.cliPinnedName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins a name the user chose, at creation or by a rename', () => {
|
||||
expect(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }).cliPinnedName).toBe('msgtest-worker');
|
||||
|
||||
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
renamed.name = '登录修复';
|
||||
expect(renamed.cliPinnedName).toBe('登录修复');
|
||||
expect((renamed as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions().cliName).toBe('登录修复');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendClaudeCustomTitle', () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
const tempTranscript = (content: string) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-claude-title-'));
|
||||
dirs.push(dir);
|
||||
const path = join(dir, 'conv.jsonl');
|
||||
if (content) writeFileSync(path, content);
|
||||
return path;
|
||||
};
|
||||
|
||||
it('appends one custom-title row after the existing rows', async () => {
|
||||
const path = tempTranscript('{"type":"user"}\n');
|
||||
expect(await appendClaudeCustomTitle(path, 'conv', ' release notes "v2" ')).toBe(true);
|
||||
const lines = readFileSync(path, 'utf8').split('\n');
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[0]).toBe('{"type":"user"}');
|
||||
expect(JSON.parse(lines[1])).toEqual({
|
||||
type: 'custom-title',
|
||||
customTitle: 'release notes "v2"',
|
||||
sessionId: 'conv',
|
||||
});
|
||||
expect(lines[2]).toBe('');
|
||||
});
|
||||
|
||||
it('never creates a transcript that does not exist yet', async () => {
|
||||
const path = tempTranscript('');
|
||||
expect(await appendClaudeCustomTitle(path, 'conv', 'title')).toBe(false);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
});
|
||||
|
||||
it('writes nothing for a blank title', async () => {
|
||||
const path = tempTranscript('{"type":"user"}\n');
|
||||
expect(await appendClaudeCustomTitle(path, 'conv', ' ')).toBe(false);
|
||||
expect(readFileSync(path, 'utf8')).toBe('{"type":"user"}\n');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createCliExecutableResolver,
|
||||
createProductionCliResolverHost,
|
||||
formatCliNotFoundMessage,
|
||||
invalidateCliExecutableResolvers,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
@@ -129,6 +130,49 @@ describe('createCliExecutableResolver', () => {
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidation drops a negative-cache backoff immediately (a CLI installed from Settings)', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/grokx');
|
||||
const h = host({ findInLoginShell, exists: vi.fn((path) => path === '/new/bin/grokx') });
|
||||
const resolver = createCliExecutableResolver({ binary: 'grokx', searchDirs: [], now: () => now }, h);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
// Still inside the backoff window: without invalidation this answers from the
|
||||
// negative cache for up to five minutes after a successful install.
|
||||
now = 1;
|
||||
invalidateCliExecutableResolvers(['grokx']);
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/grokx');
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidation drops a cached success too, so an edited binary is re-resolved', () => {
|
||||
const findOnProcessPath = vi
|
||||
.fn<() => string | null>()
|
||||
.mockReturnValueOnce('/old/bin/editedx')
|
||||
.mockReturnValue('/new/bin/editedx');
|
||||
const h = host({ findOnProcessPath, exists: vi.fn(() => true) });
|
||||
const resolver = createCliExecutableResolver({ binary: 'editedx', searchDirs: [] }, h);
|
||||
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/old/bin/editedx');
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/old/bin/editedx');
|
||||
invalidateCliExecutableResolvers(['editedx']);
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/editedx');
|
||||
// And the fresh result is cached again rather than re-probed every call.
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/editedx');
|
||||
expect(findOnProcessPath).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidation is scoped to the named binaries and leaves every other cache alone', () => {
|
||||
const findOnProcessPath = vi.fn(() => '/bin/untouchedx');
|
||||
const h = host({ findOnProcessPath, exists: vi.fn(() => true) });
|
||||
const resolver = createCliExecutableResolver({ binary: 'untouchedx', searchDirs: [] }, h);
|
||||
|
||||
resolver.resolve();
|
||||
invalidateCliExecutableResolvers(['some-other-binary']);
|
||||
resolver.resolve();
|
||||
expect(findOnProcessPath).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('doubles the retry delay per consecutive miss and caps it at five minutes', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi.fn(() => null);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Port: none (drives the real settings-ui.js in a vm context — no browser, no server).
|
||||
//
|
||||
// The CLI-management rows in App Settings (docs/cli-enable-disable-plan.md, Phase 6).
|
||||
// Installing runs a command on the server, so it must never happen on a single click:
|
||||
// the #343 review asked for auto-install to sit "behind an explicit confirm, or off".
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
function loadSettingsUi(confirmAnswer: boolean) {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const rows = { innerHTML: '' };
|
||||
const confirm = vi.fn(() => confirmAnswer);
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
confirm,
|
||||
window: {},
|
||||
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: {
|
||||
getElementById: (id: string) => (id === 'cliListRows' ? rows : null),
|
||||
createElement: () => ({ style: {}, dataset: {}, setAttribute: () => {}, appendChild: () => {} }),
|
||||
createElementNS: () => ({ style: {}, dataset: {}, setAttribute: () => {}, appendChild: () => {} }),
|
||||
querySelector: () => null,
|
||||
},
|
||||
});
|
||||
for (const file of ['constants.js', 'settings-ui.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
const app = new (CodemanApp as any)();
|
||||
app._api = vi.fn(async () => ({ ok: true, json: async () => ({ success: true }) }));
|
||||
app.showToast = vi.fn();
|
||||
app.loadCliListForSettings = vi.fn(async () => {});
|
||||
return { app, confirm, rows };
|
||||
}
|
||||
|
||||
const GROK = {
|
||||
id: 'grok',
|
||||
label: 'Grok',
|
||||
shortBadge: 'GK',
|
||||
stock: true,
|
||||
installed: false,
|
||||
enabled: true,
|
||||
installCommand: 'curl -fsSL https://x.ai/cli/install.sh | bash',
|
||||
};
|
||||
|
||||
describe('CLI management: install confirmation', () => {
|
||||
it('runs nothing when the confirm is declined', async () => {
|
||||
const { app, confirm } = loadSettingsUi(false);
|
||||
app._cliList = [GROK];
|
||||
await app.installCliEntry('grok');
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(app._api).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the exact command in the confirm, then installs on accept', async () => {
|
||||
const { app, confirm } = loadSettingsUi(true);
|
||||
app._cliList = [GROK];
|
||||
await app.installCliEntry('grok');
|
||||
expect(confirm.mock.calls[0][0]).toContain(GROK.installCommand);
|
||||
expect(app._api).toHaveBeenCalledWith('/api/clis/grok/install', { method: 'POST' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI management: list rendering', () => {
|
||||
it('lists installed CLIs first, each group alphabetical, and gives shell no switch', () => {
|
||||
const { app, rows } = loadSettingsUi(true);
|
||||
app._cliList = [
|
||||
{ id: 'pi', label: 'Pi', shortBadge: 'PI', kind: 'agent', stock: true, installed: false, enabled: true },
|
||||
{ id: 'shell', label: 'Shell', shortBadge: 'SH', kind: 'shell', stock: true, installed: true, enabled: true },
|
||||
{ id: 'codex', label: 'Codex', shortBadge: 'CX', kind: 'agent', stock: true, installed: true, enabled: true },
|
||||
{ id: 'grok', label: 'Grok', shortBadge: 'GK', kind: 'agent', stock: true, installed: false, enabled: true },
|
||||
];
|
||||
app.renderCliList();
|
||||
const order = [...rows.innerHTML.matchAll(/data-cli-id="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(order).toEqual(['codex', 'shell', 'grok', 'pi']);
|
||||
|
||||
const shellRow = rows.innerHTML.split('data-cli-id="shell"')[1].split('data-cli-id=')[0];
|
||||
expect(shellRow).toContain('Always available');
|
||||
expect(shellRow).not.toContain('type="checkbox"');
|
||||
const codexRow = rows.innerHTML.split('data-cli-id="codex"')[1].split('data-cli-id=')[0];
|
||||
expect(codexRow).toContain('type="checkbox"');
|
||||
});
|
||||
});
|
||||
@@ -314,7 +314,6 @@ describe('declared-for-later fields', () => {
|
||||
* list — and wiring one up should make its line here fail, which is the good direction.
|
||||
*/
|
||||
const DECLARED_FOR_LATER = [
|
||||
'shortBadge',
|
||||
'accent',
|
||||
'capabilities.echo',
|
||||
'capabilities.wheelForward',
|
||||
|
||||
@@ -165,6 +165,24 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
|
||||
expect(compileVersionRegex(src), `${entry.id} declares a watchingLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the optional awaitingLine to the same guard', () => {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
awaitingLine: '(a+)+b',
|
||||
};
|
||||
}, 'it is tested against a pane row every time a session settles');
|
||||
});
|
||||
|
||||
it('accepts every shipped awaitingLine', () => {
|
||||
for (const entry of STOCK_CLIS) {
|
||||
const src = entry.capabilities.workDetect?.awaitingLine;
|
||||
if (!src) continue;
|
||||
expect(compileVersionRegex(src), `${entry.id} declares an awaitingLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('no shell text can reach the command line', () => {
|
||||
|
||||
@@ -406,6 +406,61 @@ describe('Session Manager unified list', () => {
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined, undefined);
|
||||
});
|
||||
|
||||
it('keeps mode, claudeSessionId and resumeId on the row record the ⋯ menu reads', async () => {
|
||||
const { app, elements } = loadPaletteHarness({
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'codex-thread-1',
|
||||
mode: 'codex',
|
||||
resumeId: 'codex-thread-1',
|
||||
workingDir: '/repo/cx',
|
||||
firstPrompt: 'codex prompt',
|
||||
lastActivityAt: 1750000000000,
|
||||
sources: ['history'],
|
||||
},
|
||||
{
|
||||
sessionId: 'sess-resumed',
|
||||
mode: 'claude',
|
||||
claudeSessionId: 'conv-uuid-2',
|
||||
workingDir: '/repo/cl',
|
||||
lastActivityAt: 1749000000000,
|
||||
sources: ['persisted'],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
|
||||
app._buildHistoryItem = vi.fn(() => ({}));
|
||||
app.resumeHistorySession = vi.fn();
|
||||
|
||||
await app._loadSessionManagerList('');
|
||||
|
||||
// The re-projected record is also what the row's ⋯ menu reads (mode badge,
|
||||
// Resume), so these fields must survive it, not only reach onActivate.
|
||||
const [codexRecord, , codexOptions] = app._buildHistoryItem.mock.calls[0];
|
||||
expect(codexRecord).toMatchObject({ mode: 'codex', resumeId: 'codex-thread-1' });
|
||||
codexOptions.onActivate();
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith(
|
||||
'codex-thread-1',
|
||||
'/repo/cx',
|
||||
undefined,
|
||||
'codex',
|
||||
'codex-thread-1'
|
||||
);
|
||||
|
||||
const [claudeRecord] = app._buildHistoryItem.mock.calls[1];
|
||||
expect(claudeRecord).toMatchObject({ mode: 'claude', claudeSessionId: 'conv-uuid-2' });
|
||||
});
|
||||
|
||||
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
|
||||
const appended: any[] = [];
|
||||
const { app, elements } = loadPaletteHarness({
|
||||
|
||||
@@ -16,7 +16,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CronService, clampCronExternalCliConfigs, type CronDeps } from '../src/cron/cron-service.js';
|
||||
import {
|
||||
CronService,
|
||||
clampCronExternalCliConfigs,
|
||||
deliverCronPrompt,
|
||||
type CronDeps,
|
||||
} from '../src/cron/cron-service.js';
|
||||
import { CRON_PASTE_ENTER_DELAY_MS } from '../src/config/server-timing.js';
|
||||
import { CronJobSchema } from '../src/web/schemas.js';
|
||||
import { MAX_CRON_JOBS } from '../src/config/map-limits.js';
|
||||
import type { CronJob, CronJobRun } from '../src/types/cron.js';
|
||||
@@ -705,3 +711,77 @@ describe('clampCronExternalCliConfigs', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Paste mode used to write `<text>\r` in one piece. Claude Code takes a raw burst of
|
||||
* about a hundred characters as a paste and turns its `\r` into a newline, so the
|
||||
* prompt sat unsent on the composer while the run said `prompt_sent`.
|
||||
*/
|
||||
describe('deliverCronPrompt', () => {
|
||||
const PROMPT =
|
||||
'Reply with only the word ok and nothing else, this sentence is padding to reach about one hundred chars.';
|
||||
|
||||
function fakeTarget(ok = true) {
|
||||
const calls: string[] = [];
|
||||
const target = {
|
||||
write: vi.fn((d: string) => {
|
||||
calls.push(`write:${JSON.stringify(d)}`);
|
||||
return ok;
|
||||
}),
|
||||
writeViaMux: vi.fn(async (d: string) => {
|
||||
calls.push(`mux:${JSON.stringify(d)}`);
|
||||
return ok;
|
||||
}),
|
||||
verifySubmitted: vi.fn((t: string) => {
|
||||
calls.push(`verify:${JSON.stringify(t)}`);
|
||||
}),
|
||||
};
|
||||
return { target, calls };
|
||||
}
|
||||
const noWait = async (): Promise<void> => {};
|
||||
|
||||
it('paste mode writes the text and its Enter separately, then arms the composer check', async () => {
|
||||
const { target, calls } = fakeTarget();
|
||||
const waits: number[] = [];
|
||||
|
||||
const ok = await deliverCronPrompt(target, PROMPT, 'paste', async (ms) => {
|
||||
waits.push(ms);
|
||||
calls.push('wait');
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(calls).toEqual([
|
||||
`write:${JSON.stringify(PROMPT)}`,
|
||||
'wait',
|
||||
'write:"\\r"',
|
||||
`verify:${JSON.stringify(PROMPT)}`,
|
||||
]);
|
||||
expect(waits).toEqual([CRON_PASTE_ENTER_DELAY_MS]);
|
||||
expect(target.writeViaMux).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never puts the Enter in the same write as the text', async () => {
|
||||
const { target } = fakeTarget();
|
||||
|
||||
await deliverCronPrompt(target, `${PROMPT}\r`, 'paste', noWait);
|
||||
|
||||
for (const [data] of target.write.mock.calls) {
|
||||
expect(data === '\r' || !data.includes('\r')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('typed mode is unchanged: one mux write that carries the Enter', async () => {
|
||||
const { target, calls } = fakeTarget();
|
||||
|
||||
await deliverCronPrompt(target, PROMPT, 'typed', noWait);
|
||||
|
||||
expect(calls).toEqual([`mux:${JSON.stringify(`${PROMPT}\r`)}`]);
|
||||
});
|
||||
|
||||
it('reports a session it could not write to, instead of claiming the prompt went out', async () => {
|
||||
const { target } = fakeTarget(false);
|
||||
|
||||
expect(await deliverCronPrompt(target, PROMPT, 'paste', noWait)).toBe(false);
|
||||
expect(target.verifySubmitted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,15 +110,14 @@ describe('docker-compose.yaml cap_add covers what entrypoint.sh and init:true ne
|
||||
});
|
||||
|
||||
describe('the runtime-owned CLI prefix never shadows root commands', () => {
|
||||
it('server.Dockerfile appends /opt/codeman-cli/bin to PATH rather than prepending it', () => {
|
||||
it('server.Dockerfile appends the runtime-writable CLI dirs to PATH rather than prepending them', () => {
|
||||
const pathLines = dockerfile.split('\n').filter((l) => /^ENV PATH=/.test(l));
|
||||
expect(pathLines.length).toBeGreaterThan(0);
|
||||
for (const line of pathLines) {
|
||||
expect(line, 'a writable prefix ahead of $PATH lets a planted setpriv run as root').not.toMatch(
|
||||
/^ENV PATH=\/opt\/codeman-cli/
|
||||
);
|
||||
expect(line, 'a writable prefix ahead of $PATH lets a planted setpriv run as root').toMatch(/^ENV PATH=\$PATH:/);
|
||||
}
|
||||
expect(pathLines).toContain('ENV PATH=$PATH:/opt/codeman-cli/bin');
|
||||
expect(pathLines).toContain('ENV PATH=$PATH:/home/${CODEMAN_RUNTIME_USER}/.local/bin');
|
||||
});
|
||||
|
||||
it('entrypoint.sh pins PATH to the system directories before its first command', () => {
|
||||
|
||||
@@ -45,9 +45,9 @@ const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js'];
|
||||
*/
|
||||
const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
|
||||
"session-ui.js::mode === 'shell'": {
|
||||
count: 2,
|
||||
count: 3,
|
||||
reason:
|
||||
'run() dispatch (shell needs no CLI probe at all) and the button-label ternary (pinned exact ' +
|
||||
'run() dispatch, availability gating (shell needs no CLI probe at all), and the button-label ternary (pinned exact ' +
|
||||
"text — test/run-mode-ui.test.ts asserts e.g. 'Run OMP', which diverges from CliEntry.shortBadge " +
|
||||
"for at least omp ('OM' vs the displayed 'OMP'), so a catalogue-driven rewrite would silently " +
|
||||
'change user-visible text and break that pinned test; the maintainer confirmed leaving this ' +
|
||||
@@ -55,9 +55,9 @@ const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
|
||||
},
|
||||
|
||||
"session-ui.js::mode === 'claude'": {
|
||||
count: 4,
|
||||
count: 3,
|
||||
reason:
|
||||
'four claude-specific call sites, not one branch: run() dispatch (claude has its own ' +
|
||||
'three claude-specific call sites, not one branch: run() dispatch (claude has its own ' +
|
||||
'remote/docker branching and parallel-create path, unlike every RUN_MODE_LAUNCH entry), ' +
|
||||
'runCustomModelEntry() (restart-vs-one-shot launch mechanism, not a preference — see ' +
|
||||
"CLAUDE.md's Custom Model Endpoint Profiles section), the Respawn/Ralph section (claude-only " +
|
||||
@@ -65,22 +65,19 @@ const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
|
||||
'validity check',
|
||||
},
|
||||
|
||||
// The 8 external CLIs share the same two call sites and the same reason at
|
||||
// each: the button-label ternary (see the shell entry above for why it
|
||||
// stays hardcoded) and the runMode property setter's validity allowlist
|
||||
// (not a behaviour branch; left hardcoded in Phase 2 since its chain has
|
||||
// no shell arm at all and no evidence of what callers rely on it).
|
||||
"session-ui.js::mode === 'opencode'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'codex'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'gemini'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
// The 8 external CLIs remain in the button-label ternary only. The runMode
|
||||
// setter now validates custom entries through the injected registry catalog.
|
||||
"session-ui.js::mode === 'opencode'": { count: 1, reason: 'button-label ternary' },
|
||||
"session-ui.js::mode === 'codex'": { count: 1, reason: 'button-label ternary' },
|
||||
"session-ui.js::mode === 'gemini'": { count: 1, reason: 'button-label ternary' },
|
||||
"session-ui.js::mode === 'antigravity'": {
|
||||
count: 2,
|
||||
reason: 'button-label ternary + runMode setter validity check',
|
||||
count: 1,
|
||||
reason: 'button-label ternary',
|
||||
},
|
||||
"session-ui.js::mode === 'pi'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'grok'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'deepseek'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'omp'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'pi'": { count: 1, reason: 'button-label ternary' },
|
||||
"session-ui.js::mode === 'grok'": { count: 1, reason: 'button-label ternary' },
|
||||
"session-ui.js::mode === 'deepseek'": { count: 1, reason: 'button-label ternary' },
|
||||
"session-ui.js::mode === 'omp'": { count: 1, reason: 'button-label ternary' },
|
||||
|
||||
// The docker adopt-preflight status line and the docker link/adopt toast
|
||||
// both list the agent CLIs probed INSIDE the container and leave `shell`
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* @fileoverview The pre-push hook that scripts/postinstall.js installs (scripts/git-hooks.mjs).
|
||||
*
|
||||
* Two properties matter more than the hook's contents, because the older pre-commit
|
||||
* installer gets both wrong and this one must not copy it:
|
||||
* 1. It is MARKER-OWNED: a hook the developer wrote by hand is never overwritten.
|
||||
* 2. The hooks directory is resolved through git, since in a worktree `.git` is a FILE
|
||||
* and `<root>/.git/hooks` does not exist, and it is ONLY ever the repo's own
|
||||
* `<git-common-dir>/hooks`: a `core.hooksPath` elsewhere (typically a global one) is
|
||||
* never written to.
|
||||
*
|
||||
* ⚠️ Every filesystem/git test here runs against THROWAWAY repositories under a temp dir.
|
||||
* Never point the installer at this checkout: its hooks directory is shared with every
|
||||
* worktree of it, including whatever the developer is running right now.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
PRE_PUSH_CHECKS,
|
||||
PRE_PUSH_MARKER,
|
||||
PRE_PUSH_WATCHED_PATHS,
|
||||
installPrePushHook,
|
||||
planHookInstall,
|
||||
renderPrePushHook,
|
||||
resolveGitHooksDir,
|
||||
} from '../scripts/git-hooks.mjs';
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..');
|
||||
const read = (rel: string) => readFileSync(resolve(repoRoot, rel), 'utf8');
|
||||
|
||||
/** git with no user/system config leaking in (a global core.hooksPath would redirect everything). */
|
||||
const GIT_ENV = {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_AUTHOR_NAME: 'test',
|
||||
GIT_AUTHOR_EMAIL: 'test@example.invalid',
|
||||
GIT_COMMITTER_NAME: 'test',
|
||||
GIT_COMMITTER_EMAIL: 'test@example.invalid',
|
||||
CODEMAN_SKIP_PREPUSH: '',
|
||||
};
|
||||
|
||||
function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = GIT_ENV): string {
|
||||
return execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
}
|
||||
|
||||
let scratch: string;
|
||||
beforeAll(() => {
|
||||
scratch = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-git-hooks-')));
|
||||
});
|
||||
afterAll(() => {
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
let counter = 0;
|
||||
function newRepo(): string {
|
||||
const dir = join(scratch, `repo-${++counter}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
git(dir, ['init', '-q', '-b', 'main']);
|
||||
git(dir, ['commit', '-q', '--allow-empty', '-m', 'init']);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('pre-push hook body', () => {
|
||||
const hook = renderPrePushHook();
|
||||
|
||||
it('carries the ownership marker', () => {
|
||||
expect(hook).toContain(PRE_PUSH_MARKER);
|
||||
});
|
||||
|
||||
it('runs every configured check through npm, and nothing slow', () => {
|
||||
for (const args of PRE_PUSH_CHECKS) {
|
||||
expect(hook).toContain(`run_check ${args.join(' ')}`);
|
||||
}
|
||||
expect(hook).toContain('npm run --silent "$@"');
|
||||
// The whole point of the tier: the minutes-long suites stay out of a per-push hook.
|
||||
expect(hook).not.toMatch(/\btest:(ci|browser|mobile|perf|all)\b/);
|
||||
});
|
||||
|
||||
it('is POSIX sh', () => {
|
||||
expect(hook.startsWith('#!/bin/sh\n')).toBe(true);
|
||||
const r = spawnSync('sh', ['-n'], { input: hook });
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pre-push checks match the static CI job', () => {
|
||||
const scripts = JSON.parse(read('package.json')).scripts as Record<string, string>;
|
||||
const ci = read('.github/workflows/ci.yml');
|
||||
|
||||
it.each(PRE_PUSH_CHECKS.map((args) => [args.join(' ')] as const))('%s is a real script that CI runs', (joined) => {
|
||||
const [name] = joined.split(' ');
|
||||
expect(scripts[name], `package.json has no "${name}" script`).toBeTypeOf('string');
|
||||
expect(ci).toContain(`npm run ${joined}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planHookInstall', () => {
|
||||
const hook = renderPrePushHook();
|
||||
|
||||
it('writes when no hook exists', () => {
|
||||
expect(planHookInstall({ existing: null, next: hook })).toBe('write');
|
||||
});
|
||||
|
||||
it('refuses to clobber a hook it does not own', () => {
|
||||
expect(planHookInstall({ existing: '#!/bin/sh\nmake lint\n', next: hook })).toBe('skip-foreign');
|
||||
});
|
||||
|
||||
it('refreshes its own hook when the body changed', () => {
|
||||
expect(planHookInstall({ existing: `#!/bin/sh\n${PRE_PUSH_MARKER}\necho old\n`, next: hook })).toBe('write');
|
||||
});
|
||||
|
||||
it('is idempotent when already current', () => {
|
||||
expect(planHookInstall({ existing: hook, next: hook })).toBe('up-to-date');
|
||||
});
|
||||
|
||||
it('treats an empty file as absent rather than foreign', () => {
|
||||
expect(planHookInstall({ existing: ' \n', next: hook })).toBe('write');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGitHooksDir (temp repos)', () => {
|
||||
// resolveGitHooksDir runs git with process.env, so an exported GIT_CONFIG_GLOBAL or a system
|
||||
// gitconfig carrying core.hooksPath would otherwise redirect every expectation below.
|
||||
// test/setup.ts swaps HOME, which only covers ~/.gitconfig.
|
||||
const ambient = {
|
||||
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
|
||||
GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM,
|
||||
};
|
||||
beforeAll(() => {
|
||||
process.env.GIT_CONFIG_NOSYSTEM = '1';
|
||||
process.env.GIT_CONFIG_GLOBAL = '/dev/null';
|
||||
});
|
||||
afterAll(() => {
|
||||
for (const [k, v] of Object.entries(ambient)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves <root>/.git/hooks in a plain checkout', () => {
|
||||
const repo = newRepo();
|
||||
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
|
||||
});
|
||||
|
||||
it('resolves the SHARED hooks dir from a worktree, where .git is a file', () => {
|
||||
const repo = newRepo();
|
||||
const wt = join(scratch, `wt-${counter}`);
|
||||
git(repo, ['worktree', 'add', '-q', wt, '-b', 'wt-branch']);
|
||||
expect(statSync(join(wt, '.git')).isFile()).toBe(true);
|
||||
expect(resolveGitHooksDir(wt)).toBe(join(repo, '.git', 'hooks'));
|
||||
});
|
||||
|
||||
it('returns null outside any git checkout', () => {
|
||||
const dir = join(scratch, `plain-${++counter}`);
|
||||
mkdirSync(dir);
|
||||
expect(resolveGitHooksDir(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a repo-local core.hooksPath points outside the repo', () => {
|
||||
const repo = newRepo();
|
||||
const outside = join(scratch, `shared-hooks-${counter}`);
|
||||
mkdirSync(outside);
|
||||
git(repo, ['config', 'core.hooksPath', outside]);
|
||||
expect(resolveGitHooksDir(repo)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when core.hooksPath points at a directory that does not exist yet', () => {
|
||||
const repo = newRepo();
|
||||
git(repo, ['config', 'core.hooksPath', join(scratch, `missing-${counter}`, 'hooks')]);
|
||||
expect(resolveGitHooksDir(repo)).toBeNull();
|
||||
});
|
||||
|
||||
it("still resolves when core.hooksPath points at the repo's OWN .git/hooks", () => {
|
||||
const repo = newRepo();
|
||||
git(repo, ['config', 'core.hooksPath', join(repo, '.git', 'hooks')]);
|
||||
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
|
||||
});
|
||||
|
||||
it('resolves before .git/hooks exists (compares the would-be path)', () => {
|
||||
const repo = newRepo();
|
||||
rmSync(join(repo, '.git', 'hooks'), { recursive: true, force: true });
|
||||
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
|
||||
});
|
||||
|
||||
it('returns null under a GLOBAL core.hooksPath, from a checkout and from a worktree', () => {
|
||||
const repo = newRepo();
|
||||
const wt = join(scratch, `wt-global-${counter}`);
|
||||
git(repo, ['worktree', 'add', '-q', wt, '-b', 'wt-global']);
|
||||
const globalHooks = join(scratch, `global-hooks-${counter}`);
|
||||
mkdirSync(globalHooks);
|
||||
const globalConfig = join(scratch, `gitconfig-${counter}`);
|
||||
writeFileSync(globalConfig, `[core]\n\thooksPath = ${globalHooks}\n`);
|
||||
// resolveGitHooksDir runs git with the ambient environment, so scope the fake global
|
||||
// config to this test through process.env (never the developer's real ~/.gitconfig).
|
||||
const saved = {
|
||||
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
|
||||
GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM,
|
||||
};
|
||||
process.env.GIT_CONFIG_GLOBAL = globalConfig;
|
||||
process.env.GIT_CONFIG_NOSYSTEM = '1';
|
||||
try {
|
||||
expect(git(repo, ['rev-parse', '--git-path', 'hooks'], { ...GIT_ENV, GIT_CONFIG_GLOBAL: globalConfig })).toBe(
|
||||
globalHooks
|
||||
);
|
||||
expect(resolveGitHooksDir(repo)).toBeNull();
|
||||
expect(resolveGitHooksDir(wt)).toBeNull();
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
// Control: the same repo resolves again once the global setting is gone.
|
||||
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
|
||||
});
|
||||
|
||||
it("returns null for a copy nested inside someone else's repo (e.g. under node_modules)", () => {
|
||||
const repo = newRepo();
|
||||
const nested = join(repo, 'node_modules', 'aicodeman');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
expect(resolveGitHooksDir(nested)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('installPrePushHook (temp repos)', () => {
|
||||
it('writes an executable hook into a fresh repo', () => {
|
||||
const hooks = join(newRepo(), '.git', 'hooks');
|
||||
expect(installPrePushHook(hooks)).toBe('write');
|
||||
const path = join(hooks, 'pre-push');
|
||||
expect(readFileSync(path, 'utf8')).toBe(renderPrePushHook());
|
||||
expect(statSync(path).mode & 0o111).not.toBe(0);
|
||||
expect(installPrePushHook(hooks)).toBe('up-to-date');
|
||||
});
|
||||
|
||||
it('leaves a foreign pre-push hook byte-identical', () => {
|
||||
const hooks = join(newRepo(), '.git', 'hooks');
|
||||
const path = join(hooks, 'pre-push');
|
||||
const mine = '#!/bin/sh\n# my own hook\nexit 0\n';
|
||||
writeFileSync(path, mine, { mode: 0o755 });
|
||||
expect(installPrePushHook(hooks)).toBe('skip-foreign');
|
||||
expect(readFileSync(path, 'utf8')).toBe(mine);
|
||||
});
|
||||
|
||||
it('refreshes a stale managed hook and keeps it executable', () => {
|
||||
const hooks = join(newRepo(), '.git', 'hooks');
|
||||
const path = join(hooks, 'pre-push');
|
||||
writeFileSync(path, `#!/bin/sh\n${PRE_PUSH_MARKER}\necho old\n`, { mode: 0o644 });
|
||||
expect(installPrePushHook(hooks)).toBe('write');
|
||||
expect(readFileSync(path, 'utf8')).toBe(renderPrePushHook());
|
||||
expect(statSync(path).mode & 0o111).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Drive the rendered hook through a real `git push` to a local bare remote. The repo gets a
|
||||
* stub package.json whose check scripts only record that they ran, so this exercises the
|
||||
* hook's control flow (ref parsing, skips, blocking) without running the real checks.
|
||||
*/
|
||||
describe('the installed hook on a real push (temp repos)', () => {
|
||||
function setup(opts: { failing?: string; nodeModules?: boolean } = {}) {
|
||||
const repo = newRepo();
|
||||
const remote = join(scratch, `remote-${counter}.git`);
|
||||
git(scratch, ['init', '-q', '--bare', remote]);
|
||||
git(repo, ['remote', 'add', 'origin', remote]);
|
||||
const log = join(repo, 'ran.log');
|
||||
const scripts: Record<string, string> = {};
|
||||
for (const [name] of PRE_PUSH_CHECKS) {
|
||||
scripts[name] =
|
||||
name === opts.failing ? `echo ${name} >> ran.log && echo boom-${name} && exit 1` : `echo ${name} >> ran.log`;
|
||||
}
|
||||
writeFileSync(join(repo, 'package.json'), JSON.stringify({ name: 'hook-fixture', private: true, scripts }));
|
||||
writeFileSync(join(repo, '.gitignore'), 'node_modules/\nran.log\n');
|
||||
git(repo, ['add', 'package.json', '.gitignore']);
|
||||
git(repo, ['commit', '-q', '-m', 'fixture']);
|
||||
if (opts.nodeModules !== false) mkdirSync(join(repo, 'node_modules'));
|
||||
installPrePushHook(join(repo, '.git', 'hooks'));
|
||||
chmodSync(join(repo, '.git', 'hooks', 'pre-push'), 0o755);
|
||||
const ran = () => {
|
||||
try {
|
||||
return readFileSync(log, 'utf8').trim().split('\n').filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const push = (args: string[], env: NodeJS.ProcessEnv = {}) =>
|
||||
spawnSync('git', ['push', ...args], { cwd: repo, env: { ...GIT_ENV, ...env }, encoding: 'utf8' });
|
||||
return { repo, remote, ran, push };
|
||||
}
|
||||
|
||||
/** What the stubs record: npm appends the args after `--` to the script, so they prove forwarding. */
|
||||
const expectedRuns = PRE_PUSH_CHECKS.map((args) => args.filter((a) => a !== '--').join(' '));
|
||||
|
||||
it('runs every check before a push, in order', () => {
|
||||
const { ran, push } = setup();
|
||||
const r = push(['-q', 'origin', 'main']);
|
||||
expect(r.status, r.stderr + r.stdout).toBe(0);
|
||||
expect(ran()).toEqual(expectedRuns);
|
||||
});
|
||||
|
||||
it('blocks the push when a check fails, but still runs the rest', () => {
|
||||
const { ran, push, remote } = setup({ failing: 'lint' });
|
||||
const r = push(['origin', 'main']);
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('pre-push: FAILED npm run lint');
|
||||
expect(r.stdout + r.stderr).toContain('boom-lint');
|
||||
expect(ran()).toEqual(expectedRuns);
|
||||
expect(spawnSync('git', ['rev-parse', '--verify', '-q', 'refs/heads/main'], { cwd: remote }).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it('CODEMAN_SKIP_PREPUSH=1 skips every check', () => {
|
||||
const { ran, push } = setup({ failing: 'lint' });
|
||||
const r = push(['-q', 'origin', 'main'], { CODEMAN_SKIP_PREPUSH: '1' });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(ran()).toEqual([]);
|
||||
});
|
||||
|
||||
it('a delete-only push skips the checks', () => {
|
||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
||||
expect(push(['-q', 'origin', 'main'], { CODEMAN_SKIP_PREPUSH: '1' }).status).toBe(0);
|
||||
git(repo, ['branch', 'doomed']);
|
||||
expect(push(['-q', 'origin', 'doomed'], { CODEMAN_SKIP_PREPUSH: '1' }).status).toBe(0);
|
||||
const r = push(['-q', 'origin', '--delete', 'doomed']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(ran()).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips when the pushed ref is not the checked-out HEAD', () => {
|
||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
||||
git(repo, ['branch', 'other']);
|
||||
git(repo, ['commit', '-q', '--allow-empty', '-m', 'only on main']);
|
||||
git(repo, ['checkout', '-q', 'other']);
|
||||
// HEAD is `other`; pushing `main` would check a working tree that is not main's.
|
||||
const r = push(['origin', 'main']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain(
|
||||
'pre-push: skipping static checks: refs/heads/main is not the checked-out HEAD'
|
||||
);
|
||||
expect(ran()).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips when any one of several pushed refs is not HEAD', () => {
|
||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
||||
git(repo, ['branch', 'behind']);
|
||||
git(repo, ['commit', '-q', '--allow-empty', '-m', 'ahead']);
|
||||
const r = push(['origin', 'main', 'behind']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('is not the checked-out HEAD');
|
||||
expect(ran()).toEqual([]);
|
||||
});
|
||||
|
||||
it('still checks an annotated tag that points at HEAD (the tag is peeled)', () => {
|
||||
const { ran, push, repo } = setup();
|
||||
git(repo, ['tag', '-a', 'v1', '-m', 'v1']);
|
||||
const r = push(['-q', 'origin', 'v1']);
|
||||
expect(r.status, r.stderr + r.stdout).toBe(0);
|
||||
expect(ran()).toEqual(expectedRuns);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'src/wip.ts',
|
||||
'config/wip.json',
|
||||
'scripts/wip.mjs',
|
||||
'test/wip.test.ts',
|
||||
'install.sh',
|
||||
'tsconfig.json',
|
||||
'.prettierignore',
|
||||
'.editorconfig',
|
||||
])('skips when %s is untracked (another session may own it)', (rel) => {
|
||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
||||
mkdirSync(join(repo, rel, '..'), { recursive: true });
|
||||
writeFileSync(join(repo, rel), 'wip\n');
|
||||
const r = push(['origin', 'main']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('pre-push: skipping static checks: uncommitted changes under');
|
||||
expect(ran()).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips when a tracked package.json has an unstaged edit', () => {
|
||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
||||
const pkg = join(repo, 'package.json');
|
||||
writeFileSync(pkg, readFileSync(pkg, 'utf8') + '\n');
|
||||
const r = push(['origin', 'main']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('uncommitted changes under');
|
||||
expect(ran()).toEqual([]);
|
||||
});
|
||||
|
||||
it('still checks when the only uncommitted changes are outside the watched paths', () => {
|
||||
const { ran, push, repo } = setup({ failing: 'lint' });
|
||||
mkdirSync(join(repo, 'docs'));
|
||||
writeFileSync(join(repo, 'docs', 'notes.md'), 'draft\n');
|
||||
writeFileSync(join(repo, 'README.md'), 'draft\n');
|
||||
const r = push(['origin', 'main']);
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('pre-push: FAILED npm run lint');
|
||||
expect(ran()).toEqual(expectedRuns);
|
||||
});
|
||||
|
||||
it('watches exactly the paths the checks read', () => {
|
||||
expect(PRE_PUSH_WATCHED_PATHS).toEqual([
|
||||
'src',
|
||||
'config',
|
||||
'scripts',
|
||||
'test',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'install.sh',
|
||||
'tsconfig.json',
|
||||
'.prettierignore',
|
||||
'.editorconfig',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips (never blocks) when node_modules is absent', () => {
|
||||
const { ran, push } = setup({ failing: 'lint', nodeModules: false });
|
||||
const r = push(['origin', 'main']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('node_modules missing');
|
||||
expect(ran()).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips (never blocks) when npm is not on PATH, as under a GUI git client', () => {
|
||||
const { ran, push } = setup({ failing: 'lint' });
|
||||
// A PATH holding only what git and the hook need, and no npm/node. Symlinks rather than
|
||||
// the real directories, since /usr/bin usually holds npm right next to git.
|
||||
const bin = join(scratch, `bin-${counter}`);
|
||||
mkdirSync(bin);
|
||||
for (const tool of ['git', 'sh', 'mktemp', 'tail', 'rm', 'cat']) {
|
||||
const found = spawnSync('sh', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).stdout.trim();
|
||||
expect(found, `${tool} not found on the test PATH`).toMatch(/^\//);
|
||||
symlinkSync(found, join(bin, tool));
|
||||
}
|
||||
expect(spawnSync('sh', ['-c', 'command -v npm'], { env: { PATH: bin } }).status).not.toBe(0);
|
||||
const r = push(['origin', 'main'], { PATH: bin });
|
||||
expect(r.status, r.stderr + r.stdout).toBe(0);
|
||||
expect(r.stdout + r.stderr).toContain('pre-push: npm not on PATH, skipping checks.');
|
||||
expect(ran()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postinstall wiring', () => {
|
||||
const postinstall = read('scripts/postinstall.js');
|
||||
|
||||
it('installs the pre-push hook through the shared module', () => {
|
||||
expect(postinstall).toContain("import('./git-hooks.mjs')");
|
||||
expect(postinstall).toContain('installPrePushHook(gitHooksDir)');
|
||||
});
|
||||
|
||||
it('resolves the hooks dir through git, so worktrees work', () => {
|
||||
expect(postinstall).toContain('resolveGitHooksDir(');
|
||||
expect(postinstall).not.toContain("join(import.meta.dirname, '..', '.git', 'hooks')");
|
||||
});
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
||||
expect(app).not.toContain('earlier output truncated for performance');
|
||||
});
|
||||
|
||||
it('loads a bounded shell tail first and keeps full history user-triggered', () => {
|
||||
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
|
||||
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
|
||||
@@ -131,10 +131,13 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
|
||||
// an abort deadline (a `?full=1` body can be megabytes and used to hang
|
||||
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
|
||||
// decision this guard exists to pin are unchanged.
|
||||
expect(app).toContain('this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true })');
|
||||
expect(app).toContain(': `/api/sessions/${sessionId}/terminal?full=1`,\n { full: true }');
|
||||
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
|
||||
expect(app).toContain("if (session?.mode === 'shell')");
|
||||
expect(app).toContain("if (!force && session?.mode === 'shell') return;");
|
||||
// A shell scroll gesture pulls a BOUNDED window of full history; only the
|
||||
// button pulls all of it (behaviour pinned in shell-scroll-history-pull.test.ts).
|
||||
expect(app).toContain("const boundedShellPull = !force && session?.mode === 'shell';");
|
||||
expect(app).toContain('`/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`');
|
||||
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user