Compare commits

..
Author SHA1 Message Date
Codeman maintainer d55bc28a8e feat(mobile): search box in the Select Case picker
The phone case picker had no way to narrow a long case list, so finding
one meant scrolling a sheet that showed about six rows at a time.

- A search field filters rows by name (every typed word must match, any
  order, case-insensitive), with a "No matching cases" state. Enter picks
  the case when exactly one row is left; Escape clears, then closes.
- The field is not auto-focused, so opening the picker does not raise the
  keyboard. The list holds its unfiltered height while searching so the
  sheet does not jump, and the input is 16px so iOS Safari does not zoom.
- Layout: the sheet padded the home-indicator inset on top of the footer
  already doing so, leaving a dead band under Create New Case; the sheet
  now grows to 80dvh and the list fills it instead of a separate 50vh cap.
- Opening scrolls the list (its own box, not scrollIntoView) to the
  currently selected case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:56:23 +02:00
Codeman maintainer d67da5c9d0 fix(input): an oversized paste no longer poisons the durable input queue (#484)
A single input over MAX_INPUT_LENGTH (64 KiB) was queued for reliable
delivery, refused by both transports (the WebSocket silently, POST with a
400), and never dropped: the client treated the 400 as transient, so the
frame was re-sent every 2 s forever, blocked every later input for that
session, and came back from localStorage on every reload.

- Client: a paste over the frame limit is split into in-limit frames
  (never cutting a surrogate pair) delivered in seq order; over 1 MiB, or
  an oversized mux write, it is refused with a toast and never queued.
- Client: the POST drain drops a frame answered 400/413; a WS error ACK
  drops it too; frames over the limit persisted by an older build are
  pruned on load.
- Server: the WebSocket answers an oversized sequenced frame with
  {t:'ia',seq,err:'too_large',max} instead of silence (an older client
  reads that as a plain ACK and drops it); the POST schema uses
  MAX_INPUT_LENGTH instead of a second 100000 limit.

Verified end to end on an isolated instance: a 110 KB paste reached the
PTY byte-identical over both the WebSocket and the POST path, a poisoned
120 KB persisted frame was pruned on load, and a 2 MB paste showed the
refusal toast with nothing queued.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 18:17:06 +02:00
Codeman maintainer e6ddb0485a chore: version packages (1.33.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:57:55 +02:00
Ark0NandClaude 334884e96a feat(models): offer Opus 5.5 in the model picker and task routing (#480)
Adds claude-opus-5-5 to the App Settings model picker (base option with
data-ctx="1" plus its [1m] companion row, since Opus 5.5 has a 1M window)
and to the five task-routing selects, mirroring how Fable 5.1 was added.

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 01:57:35 +02:00
69a71287e6 fix(sessions): stop pinning the w1-myapp placeholder as Claude's /resume title (#457)
* fix(sessions): stop pinning the w1-myapp placeholder as Claude's /resume title

Local claude spawns passed the tab name as `--name`. That flag is not only the
cross-session peer name: it is also the prompt-box label, the `/resume` picker
entry and the terminal title, and a pinned title stops Claude generating its own
(`customTitle ?? aiTitle`). So every conversation of a case was listed in
`/resume` as the same `w1-myapp`, and none of them got a generated title. On one
workspace, 34 of 34 conversations spawned with `--name` had no ai-title, while
every conversation spawned without it had one.

Only a name the user chose is pinned now: `Session.cliPinnedName` is the name
when `nameSource === 'manual'`, carried to the builders as a separate `cliName`
so the tab/mux name is untouched. Placeholder and auto names let Claude title
the conversation again.

A rename in Codeman also reaches `/resume`: the new name is appended to the
conversation's transcript as the `custom-title` row `/rename` writes (never
creating the file, never writing an empty title). For a pane spawned without
`--name` this holds immediately; a pane spawned with one re-appends its own
title each turn, so there the new name holds from the next spawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sessions): skip no-op renames and docker sessions when syncing the /resume title

A same-name PUT (the Session Options field saves on blur and recomposes the
unchanged placeholder) no longer flips nameSource to manual or appends a
custom-title row, and docker sessions skip the host transcript scan since their
transcript lives in the container. The skill pages no longer use a w<N>- name
as the peer-name example, and the changeset notes the re-append caveat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: record that nameSource decides --name and renames reach /resume

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: codeman-local <codeman@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 01:48:33 +02:00
Julian MartinezandClaude Opus 5.5 7485afecaf fix(session-manager): carry mode, claudeSessionId and resumeId into rows (#477)
_loadSessionManagerList() re-projects each unified item into the
history-record shape _buildHistoryItem renders, and dropped these three
fields. The row's own onActivate still read them from the unified item, but
everything built from the record did not: the ⋯ menu's "Resume session"
relaunched a codex row as claude (no mode, no resumeId), a resumed session
lost its conversation id, and Cmd+K rows showed no mode badge. Same class
of bug as the worktree fields the re-projection already carries (#266).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:48:29 +02:00
DevvynandClaude Opus 5.5 0a52a99ca9 feat(cli-registry): CLI management write API + Settings UI (Phases 1-6) (#476)
* feat(cli-registry): add cliManagementEnabled flag and GET /api/clis

Phases 1-2 of docs/cli-enable-disable-plan.md ("PR C" from the #343
review): a synced, default-OFF master flag gating the upcoming CLI
management surface, plus a read-only GET /api/clis endpoint listing
every registry entry (stock + custom, enabled or not) for the
Settings UI. Non-admins in multi-user mode see an empty list rather
than a 403. Write endpoints, auto-install, custom entry CRUD and the
Settings UI list itself land in later phases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* feat(cli-registry): Phases 3-6 - write API + custom entries + Settings UI

Completes docs/cli-enable-disable-plan.md ("PR C" from the #343 review).

Phase 3: PUT /api/clis/:id toggles enabled for any EXISTING entry (stock or
custom) via a shallow merge onto its clis.json override; shell/claude are
structurally un-disableable (Decision 4), an unknown id 404s rather than
becoming a creation backdoor.

Phase 4: POST /api/clis/:id/install runs a STOCK entry's already-vetted
install command (shell:true, bounded by timeout, process-group killed on
expiry, output captured, audit-logged). A custom entry's id is refused
outright, independent of anything Phase 5 does (Decision 3: a custom
entry's install text is display-only, never executed).

Phase 5: POST /api/clis (create) / PUT /api/clis/custom/:id (update) /
DELETE /api/clis/:id (custom only) — a deliberately minimal request shape
(id/label/shortBadge/binaries/a simple launch variant), assembled into a
full CliEntry with conservative capability defaults and re-validated
through CliEntrySchema before writing, never a relaxed path for
UI-originated entries. Stock-id collisions, duplicate custom ids, and
edits/deletes against a stock id are all rejected explicitly.

Phase 6: the Settings UI section (App Settings -> Agents & CLIs), gated
independently on cliManagementEnabled AND admin-in-multi-user-mode
(Decision 5), fetching/rendering GET /api/clis and wiring every write
endpoint above.

Every write endpoint answers the same way when the feature is off: 403
FORBIDDEN via one shared requireCliManagementGate() (Phase 1's own
checklist item). registry-writer.ts is a new, deliberately separate write
module so registry.ts itself stays import-side-effect-free, same tmp+
rename+0600 shape as custom-model-hosts.ts.

27 new/updated route tests covering every gate, collision, and cleanup
path; full CI gate green (415/416 files, 7854 tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): toggling a CLI off in Settings never hid it anywhere else

window.__codemanCliAvailable — the flag isCliAvailable() reads client-side
to gate the welcome-screen buttons, the Run-menu dropdown and the mobile
overview — was 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 via the new Settings UI (or a hand-edited
clis.json) updated the settings 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.

Fixed in 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.
- 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 — without this half, 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).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* docs(cli-registry): update the CLI-management plan with status, gotchas, and the Run-menu gap

Phases 1-6 were implemented across two commits (da07b38c, db4557d9) with no
corresponding update to the plan doc itself — every checklist still read
Status: TODO and every box unchecked. Brings the doc in line with the tree:

- A new "Status as of 2026-09-22" section up top: what's actually
  implemented (verified by grepping the routes/schema/UI, not just trusting
  the commit messages), the availability-flag staleness bug found and fixed
  in this session (commit 0c77dd0a) with its devbox verification record, and
  one real outstanding gap.

- The outstanding gap: a custom CLI created via Phase 5's write API has no
  way to actually be launched. The Run menu is static per-mode markup with
  no consumer of window.__codemanCliCatalog, so Phase 6's own "create a
  custom entry, confirm it can be launched" verify step was never actually
  exercised against this. Documented with two candidate fixes, neither
  started.

- Each phase's checklist flipped to [x] where confirmed present in the tree,
  Status lines updated from TODO to DONE, and the two originally-open
  questions (Phase 2's installed source, Phase 5's PUT endpoint shape)
  marked resolved against what actually shipped.

No code changes in this commit — documentation only, so a future session
(or the one already mid-flight on a separate checkout of this same branch)
picks up accurate status instead of a stale plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* docs: add the CLI-registry deployment plan and the parked Copilot plan

Both were sitting as untracked scratch files in the master checkout,
never committed to any branch. Moving them here rather than leaving them
loose:

- DEPLOYMENT_PLAN.md is the live tracker for the CLI-registry follow-up
  series (PR A #347 merged, PR B #380 merged, PR B2 merged as #458) and
  is where PR C (this branch's own CLI-management work) belongs.
- docs/copilot-integration-plan.md is explicitly PARKED, referenced by
  name in docs/cli-enable-disable-plan.md's own header as a sibling plan
  tracked separately — kept for continuity, not active on this branch.

The other scratch files found alongside these (PRA.md, PRB.md, PR-B2.md
and their review-response counterparts) described PR A/B/B2, all now
merged — deleted from the master checkout as stale rather than committed
anywhere, since their content is superseded by the real merged PRs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* fix(cli-registry): render enabled CLIs in launch surfaces

* test(cli-registry): update frontend branch guard

* fix(test): isolate suite from deployment environment

* fix(cli-registry): revise Decision 4 - claude is toggleable, shell stays permanent

shell/claude were both structurally un-disableable in the original plan
(Decision 4). Revised: shell keeps the hard backend guarantee (it is the
one non-agent mode several code paths assume always exists as a raw-
terminal fallback), but claude is now a normal toggleable entry like any
other CLI.

Safe to do because internal session creation (tmux-manager.ts, session.ts,
Ralph, plan-orchestrator) resolves a CLI via getCli(), which does not
check `enabled` at all - only the Run menu and the HTTP-facing
sessionModeSchema() (new session requests through the normal API) key off
it. Disabling claude therefore behaves identically in kind to disabling
any other CLI: no internal fallback path breaks, it just stops being
offered for new sessions until re-enabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): hide shell's toggle entirely instead of greying it out

A permanently-disabled switch next to every other row's working toggle
read as broken rather than intentional. shell now renders 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. Backend guard is
unchanged (UNDISABLEABLE_IDS still refuses shell unconditionally); this
is UI-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): sort the Installed CLIs list, installed-first then alphabetical

renderCliList() previously rendered in registry order (each entry's fixed
order field). Now sorts installed CLIs first, then not-installed, each
group alphabetical by label - matches how a user actually scans the list
(what's ready to use, then what needs installing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* style: prettier fixes from the master merge

* fix(cli-registry): install/edit take effect immediately, confirm before install, phone labels

Four gaps found verifying #476 against the #343 review trail:

- Installed or edited CLIs kept reading as missing/stale. Every binary lookup
  (the nine per-CLI resolvers and the generic registry one) caches in its own
  closure, with a negative-cache backoff of up to 5 minutes, and nothing
  cleared them. invalidateCliExecutableResolvers(binaries) now drops those
  caches per binary; install (success or failure), create, edit and delete
  call it plus invalidateCliResolverCache(id). Before this, a CLI installed
  from Settings could fail to launch for minutes, and an edited custom entry
  kept launching its old binary until a restart.
- The Settings "installed" badge for a custom entry used a private `which`,
  ignoring the entry's searchDirs and the login-shell lookup that spawn and
  the Run menu use; it now asks the same generic resolver they do.
- Install ran on a single click. The #343 review asked for auto-install to
  sit behind an explicit confirm; the confirm now names the exact command,
  which GET /api/clis returns for stock entries only (installCommand).
- The phone Run button showed the two-letter tab badge ("CC", "CX") instead
  of the word ("Claude", "Codex"). It uses the registry label again, which is
  identical to the old static table for every stock CLI (now pinned).

14 new tests; 9 of them fail against the previous head and pass here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): address #476 review — safe serialized writes, no id branches, docs

Must-fix:
- registry-writer: start fresh only on ENOENT; refuse (409) a clis.json that
  does not parse or has group/world permission bits instead of overwriting it
  (isUnsafePermissions now exported from registry.ts)
- mutateRegistryFile(): one promise chain for every mutation, with the
  existence/duplicate checks inside the serialized step, plus a unique tmp
  name per write
- docs: CLAUDE.md, architecture-invariants, cli-registry (new Settings
  section) and api-reference (the six /api/clis routes)
- drop DEPLOYMENT_PLAN.md and docs/copilot-integration-plan.md

Smaller:
- PUT /api/clis/custom/:id keeps the entry's current enabled state when the
  body omits it
- runMode setter falls back to the first enabled catalogue entry, not 'claude'
- shell guard keyed on kind === 'shell' (routes + Settings list); stock probe
  map shared with server.ts via utils/cli-installed-probes.ts
- stock claude label is now 'Claude Code', so the Run menu / phone overview
  label rewrites are gone (doctor row keeps "Claude CLI" via its override)
- welcome buttons are translatable again and read "Run Claude Code" /
  "Run Shell"; zh-CN gains "Run Codex" / "Run OMP"
- install: per-id in-flight guard (409) and CODEMAN_* stripped from its env
- fileoverview / CliEnableSchema comments no longer say stock-only
- test-env isolation changes moved to their own PR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* test(cli-registry): pin the #343/#347 findings #476 makes reachable

A CLI toggled or created through the routes is accepted or rejected by
CreateSessionSchema with no restart (#343 finding 2), and a custom CLI created
through the API renders a real local, remote and docker launch command
(#347 finding 5: no more `cd <path> && undefined`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 01:48:26 +02:00
Ark0NandClaude Opus 5.5 c46e87fd7a fix(self-update): stalled status and hung shutdown on launchd-daemon installs (#478)
* fix(self-update): stop a stalled status from blocking every later update

A Homebrew node upgrade under a long-running server deletes the versioned
Cellar path the server passes as --node, so every status write from the
updater failed. The update itself still built and restarted (npm and the
build use node from PATH), but update-status.json stayed "queued" forever.
The boot reconcile ran one minute after the restart, inside its 15 min
window, and isInFlight() had no age limit, so "An update is already in
progress." blocked every later update until the next server restart.

- self-update.sh falls back to node on PATH when --node is not executable.
- expireStalledStatus() (pure) fails an in-flight status whose last write
  is older than the stale window; applied on every read (start + status
  poll) and persisted. The live updater heartbeats every few seconds, so a
  running update never trips it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(self-update): a hung graceful shutdown no longer leaves a LaunchDaemon install down

On a KeepAlive LaunchDaemon (headless macOS) the updater restarts by sending
the server SIGTERM and letting launchd respawn it. launchd only respawns once
the process EXITS, and nothing escalates a stuck stop (systemd would SIGKILL
after TimeoutStopSec). Observed after an update to 1.32.1: the server closed
port 3000, server.stop() never resolved, the process stayed alive and the
service stayed down until it was killed by hand.

- cli.ts: the signal handler arms an unref'd 10s timer that force-exits if
  server.stop() hangs.
- self-update.sh (launchd-daemon): wait up to 30s for the server pid to exit,
  then SIGKILL it. tmux sessions live outside the server and survive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Codeman maintainer <noreply@anthropic.com>
2026-09-24 01:35:27 +02:00
DevvynandClaude Opus 5.5 dd230b0b6e fix(test): strip every inherited CODEMAN_* var and move quick-start off 3099 (#479)
Split out of #476. A Docker Compose deployment exports CODEMAN_CASES_PATH,
which bypasses the temp HOME, so route tests wrote into the real case root.


Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:35:23 +02:00
71 changed files with 3848 additions and 311 deletions
+1 -1
View File
@@ -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.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+25
View File
@@ -1,5 +1,30 @@
# aicodeman
## 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
+3 -3
View File
@@ -77,7 +77,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.0 (must match `package.json`)
## Project Overview
@@ -229,7 +229,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` 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`) 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 +243,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.
+1 -1
View File
@@ -1,7 +1,7 @@
[
{
"id": "claude",
"label": "Claude",
"label": "Claude Code",
"shortBadge": "CC",
"enabled": true,
"order": 0,
+10
View File
@@ -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`.
+13
View File
@@ -691,6 +691,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
+4 -2
View File
@@ -109,11 +109,11 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different
⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression.
⚠️ Six fields are DECLARED-FOR-LATER and read by nothing (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/`keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed rather than measured (except `accent`, measured against styles.css on 2026-09-21), so re-measure before wiring one up; the list is pinned so it cannot quietly grow.
⚠️ Five fields are DECLARED-FOR-LATER and read by nothing (`accent`, `capabilities.echo`/`wheelForward`/`keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed rather than measured (except `accent`, measured against styles.css on 2026-09-21), so re-measure before wiring one up; the list is pinned so it cannot quietly grow. (`shortBadge` left the list when the Settings CLI management list started showing it.)
Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.test.ts`, remote/docker pane commands in `test/location-overlay-commands.test.ts`. ⚠️ That second golden no longer covers **remote claude or remote omp**: both now have their own arm in `buildRemoteLaunchCommand` (a `--session-id || --resume` pair, and `--continue`, so a respawn continues the same conversation) and never reach `defaultRemoteCommandForMode`, which is what that test asserts. Their real pins are `toContain` substrings in `test/tmux-manager.test.ts` and `test/remote-shared-sessions.test.ts`; changing either arm will NOT fail the golden.
⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry (read-only in this release; nothing writes it, so importing the registry has no filesystem side effects). User guide: `docs/cli-registry.md`.
⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry. Importing the registry still has no filesystem side effects: the only writer is `registry-writer.ts`, imported only by `cli-registry-routes.ts` (the opt-in CLI management routes, `cliManagementEnabled`, default OFF). ⚠️ Every write goes through `mutateRegistryFile()`: mutations run one at a time on a single promise chain, each with a unique temp file (three parallel toggles once lost two and 500'd on a shared temp name), and a file the READER would ignore (group/world permission bits) or quarantine (unparseable) is REFUSED with a 409 rather than rewritten, so one Settings click cannot replace a hand-edit or turn a refused file into trusted 0600 config. Only a missing file starts fresh. After a write the registry is reloaded, so changes apply without a restart. User guide: `docs/cli-registry.md`.
## Session data and lifecycle
@@ -199,6 +199,8 @@ Further detail (current geometry and colors, superseding the dip numbers above w
**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `agentSkillEnabled`, `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt.
**`nameSource` also decides what claude is told to call the conversation.** `--name` is the prompt-box label, the `/resume` picker entry and the terminal title at once, and a pinned title stops Claude generating its own, so only a `manual` name is passed (`Session.cliPinnedName`); pinning the `w1-myapp` placeholder gave every conversation of a case the same `/resume` entry. A rename through `PUT /api/sessions/:id/name` is carried into `/resume` by appending the `custom-title` row `/rename` writes (`claude-session-title.ts`), for local transcripts only: a remote pane's lives on the remote host and a docker pane's inside the container. The route returns early on an unchanged name, because the Session Options field saves on blur and recomposes the same placeholder, which would otherwise flip it to `manual` and re-pin it.
Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`) is the one `parseSessionPrefix()` (app.js, #232) already renders as the title alone with the prefix in the tooltip, and that `_nextCaseSessionStartNumber()` still counts. The `name` setter is reached via `PUT /api/sessions/:id/name`. The listener lives in `session-listener-wiring.ts`. Tests: `test/session-auto-name.test.ts`, `test/session-listener-wiring.test.ts`, `test/routes/session-name-routes.test.ts`.
### Full-scrollback replay
+314
View File
@@ -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.
+12 -2
View File
@@ -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
@@ -149,7 +159,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.
+27
View File
@@ -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).
+1 -1
View File
@@ -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')
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.32.1",
"version": "1.33.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.32.1",
"version": "1.33.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.32.1",
"version": "1.33.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+1 -1
View File
@@ -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.0",
"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
+20 -1
View File
@@ -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."
+1 -1
View File
@@ -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.
+10 -5
View File
@@ -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
+3 -2
View File
@@ -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
+45
View File
@@ -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
View File
@@ -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) {
+115
View File
@@ -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;
}
+14 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
+6 -4
View File
@@ -670,12 +670,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
+3 -1
View File
@@ -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'] },
+15 -1
View File
@@ -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;
+16 -1
View File
@@ -1560,6 +1560,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);
}
@@ -2093,6 +2106,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,
@@ -2561,6 +2575,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 +2714,7 @@ export class Session extends EventEmitter {
this._model,
this._allowedTools,
this._effort,
this._name,
this.cliPinnedName,
getClaudeCliVersion()
);
this.ptyProcess = spawnPtyWithHelperRepair(() =>
+5 -4
View File
@@ -871,7 +871,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
@@ -2054,6 +2054,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
mode,
name,
cliName,
niceConfig,
model,
claudeMode,
@@ -2157,7 +2158,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId,
effort,
statusLineCommand,
sessionName: name,
sessionName: cliName,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -2385,7 +2386,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 +2423,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);
+31
View File
@@ -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
+69
View File
@@ -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);
}
+55 -15
View File
@@ -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);
}
}
}
+48
View File
@@ -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,
+4
View File
@@ -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': '新建案例',
+73 -60
View File
@@ -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 &amp; 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">
@@ -3196,7 +3204,12 @@
<h3>Select Case</h3>
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">&times;</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>
+21 -3
View File
@@ -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',
@@ -527,7 +545,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 +600,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';
+4 -2
View File
@@ -2165,9 +2165,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;
}
+3
View File
@@ -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, {
+129 -14
View File
@@ -123,6 +123,19 @@ const RUN_MODE_LAUNCH = {
* ninth CLI landed in one and not the other.
*/
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, {
/**
@@ -511,7 +524,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 +557,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 +616,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 +641,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 +1677,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 +1705,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 +2174,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 +2198,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 +2209,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 +2348,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
@@ -4452,6 +4506,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 +4529,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 +4656,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
View File
@@ -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?.();
});
+86 -1
View File
@@ -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 {
+553
View File
@@ -0,0 +1,553 @@
/**
* @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.
*/
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;
}
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;
});
}
+1
View File
@@ -38,3 +38,4 @@ export {
type CustomModelSessionLike,
type CustomModelSwapDisplacement,
} from './custom-model-routes.js';
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
+25
View File
@@ -176,6 +176,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 +1163,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 };
});
@@ -2392,6 +2397,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,
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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: '',
+47 -41
View File
@@ -71,7 +71,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';
@@ -201,6 +202,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 +1129,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.
@@ -1620,56 +1623,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
+23 -1
View File
@@ -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
+84
View 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');
});
});
+44
View File
@@ -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);
+88
View File
@@ -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',
+55
View File
@@ -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({
+15 -18
View File
@@ -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`
+108
View File
@@ -0,0 +1,108 @@
/**
* @fileoverview Oversized input must never poison the durable input queue (#484).
*
* The failure: a paste over MAX_INPUT_LENGTH was queued for reliable delivery,
* refused by both transports (the WebSocket silently, the POST with a 400), and
* never dropped by the client, which treated a 400 like a transient failure. It
* was re-sent every 2 s forever, blocked every later input for that session
* behind it, and came back from localStorage on every reload.
*
* The fix, pinned here: the client splits a large paste into in-limit frames
* (one limit, shared with the server), drops a frame the server refused for
* good, and prunes oversized frames persisted by an older build.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import { MAX_INPUT_LENGTH } from '../src/config/terminal-limits.js';
import { SessionInputWithLimitSchema } from '../src/web/schemas.js';
const pub = (f: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', f), 'utf8');
const appSource = pub('app.js');
type InputLimit = { FRAME_MAX_CHARS: number; PASTE_MAX_CHARS: number; split: (d: string, max?: number) => string[] };
function loadLimit(): InputLimit {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(pub('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanInputLimit: InputLimit }).CodemanInputLimit;
}
describe('one input limit on both sides', () => {
it('the frontend frame limit equals the server MAX_INPUT_LENGTH', () => {
expect(loadLimit().FRAME_MAX_CHARS).toBe(MAX_INPUT_LENGTH);
});
it('the composer budget is derived from the same number', () => {
expect(pub('keyboard-accessory.js')).toMatch(
new RegExp(`COMPOSER_INPUT_FRAME_LIMIT = (64 \\* 1024|${MAX_INPUT_LENGTH});`)
);
expect(MAX_INPUT_LENGTH).toBe(64 * 1024);
});
it('the POST schema caps input at MAX_INPUT_LENGTH, not a second number', () => {
expect(SessionInputWithLimitSchema.safeParse({ input: 'x'.repeat(MAX_INPUT_LENGTH) }).success).toBe(true);
expect(SessionInputWithLimitSchema.safeParse({ input: 'x'.repeat(MAX_INPUT_LENGTH + 1) }).success).toBe(false);
});
});
describe('splitInputFrames', () => {
const { split } = loadLimit();
it('returns a short input as one frame and nothing for empty input', () => {
expect(split('abc')).toEqual(['abc']);
expect(split('')).toEqual([]);
});
it('splits a large paste into in-limit frames that rejoin byte-identically', () => {
const paste = '\x1b[200~' + 'log line\n'.repeat(15000) + '\x1b[201~';
const frames = split(paste);
expect(frames.length).toBeGreaterThan(1);
for (const f of frames) expect(f.length).toBeLessThanOrEqual(MAX_INPUT_LENGTH);
expect(frames.join('')).toBe(paste);
});
it('never cuts a surrogate pair in half', () => {
const paste = 'a' + '😀'.repeat(10); // pairs start at odd offsets
const frames = split(paste, 4);
expect(frames.join('')).toBe(paste);
for (const f of frames) {
const last = f.charCodeAt(f.length - 1);
expect(last >= 0xd800 && last <= 0xdbff).toBe(false);
const first = f.charCodeAt(0);
expect(first >= 0xdc00 && first <= 0xdfff).toBe(false);
}
});
});
describe('the client never queues or keeps an undeliverable frame', () => {
const sendAsync = appSource.slice(
appSource.indexOf('_sendInputAsync(sessionId, input, opts) {'),
appSource.indexOf('_sendInputEphemeral(sessionId, input) {')
);
it('splits an oversized paste, and refuses an oversized mux write or giant paste with a toast', () => {
expect(sendAsync).toContain('limit.split(input)');
expect(sendAsync).toMatch(/useMux \|\| input\.length > limit\.PASTE_MAX_CHARS/);
expect(sendAsync).toContain('showToast');
});
it('drops a POST the server refused as invalid instead of retrying it forever', () => {
const drain = appSource.slice(appSource.indexOf('_drainSession(sessionId) {'));
const body = drain.slice(0, drain.indexOf('_dropRejectedInput(sessionId, rec) {'));
expect(body).toMatch(
/resp\.status === 400 \|\| resp\.status === 413\)\) \{\s*[\s\S]{0,400}this\._dropRejectedInput\(sessionId, rec\);/
);
});
it('drops a frame the WebSocket refused with an error ACK', () => {
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg) {'));
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
});
it('prunes oversized frames persisted by an older build on load', () => {
const load = appSource.slice(appSource.indexOf('_loadReliableState() {'));
expect(load.slice(0, 3000)).toMatch(/r\.data\.length <= frameMax/);
});
});
+53
View File
@@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
@@ -538,3 +539,55 @@ describe('mobile overview watching badge', () => {
);
});
});
describe('mobile overview Run picker, driven by the registry catalogue', () => {
function loadRunModes(catalog: unknown[] | undefined) {
const context = vm.createContext({
CodemanApp: function CodemanApp() {},
console,
window: catalog ? { __codemanCliCatalog: catalog } : {},
document: {
getElementById: () => null,
createElement: () => fakeElement(),
createElementNS: () => fakeElement(),
},
MobileDetection: { getDeviceType: () => 'mobile' },
});
for (const file of ['constants.js', 'mobile-overview.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
return {
modes: JSON.parse(JSON.stringify(vm.runInContext('mobileOverviewRunModes()', context))),
staticTable: JSON.parse(JSON.stringify(vm.runInContext('MOBILE_OVERVIEW_RUN_MODES', context))),
};
}
it('keeps the Run button on its word label, never the two-letter tab badge', () => {
const { modes } = loadRunModes([
{ id: 'claude', label: 'Claude Code', shortBadge: 'CC', kind: 'agent', enabled: true },
{ id: 'codex', label: 'Codex', shortBadge: 'CX', kind: 'agent', enabled: true },
{ id: 'grok', label: 'Grok', shortBadge: 'GK', kind: 'agent', enabled: false },
{ id: 'shell', label: 'Shell', shortBadge: 'SH', kind: 'shell', enabled: true },
]);
expect(modes).toEqual([
{ mode: 'claude', label: 'Claude Code', short: 'Claude Code' },
{ mode: 'codex', label: 'Codex', short: 'Codex' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
]);
});
it('renders every stock CLI exactly as the static fallback table did', () => {
// The catalogue-driven path must be byte-identical to the pre-registry table for the
// shipped CLIs; only a CLI the table never knew (a custom entry) may differ.
const catalog = STOCK_CLIS.map((e) => ({
id: e.id,
label: e.label,
shortBadge: e.shortBadge,
kind: e.kind,
enabled: true,
}));
const { modes, staticTable } = loadRunModes(catalog);
const byMode = (list: Array<{ mode: string }>) => [...list].sort((a, b) => a.mode.localeCompare(b.mode));
expect(byMode(modes)).toEqual(byMode(staticTable));
});
});
+2 -2
View File
@@ -4,7 +4,7 @@ import { existsSync, rmSync, mkdirSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const TEST_PORT = 3099;
const TEST_PORT = 3299;
const ORIGINAL_HOME = process.env.HOME;
const TEST_HOME = mkdtempSync(join(tmpdir(), 'codeman-quick-start-'));
const CASES_DIR = join(TEST_HOME, 'codeman-cases');
@@ -33,7 +33,7 @@ describe('Quick Start API', () => {
server = await createTestServer(TEST_PORT);
await server.start();
baseUrl = `http://localhost:${TEST_PORT}`;
});
}, 30000);
afterEach(() => {
// Clean up cases created during this test
+56 -2
View File
@@ -23,8 +23,11 @@ import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-c
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
import { enabledClis } from '../src/config/cli-registry/registry.js';
import { enabledClis, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { dataPath } from '../src/config/instance.js';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
// renderIndexHtml probes the real PATH for every CLI, which would make the
// assertions below depend on whatever happens to be installed on the machine
@@ -207,9 +210,59 @@ describe('WebServer.renderIndexHtml', () => {
omp: true,
cloudflared: true,
git: true,
shell: true,
});
});
it('reads as unavailable for a CLI disabled via the registry, even though it is installed', async () => {
// The bug this guards: a CLI toggled off in Settings (docs/cli-enable-disable-plan.md)
// still offered itself in the welcome screen / Run menu / mobile overview, because
// window.__codemanCliAvailable was built purely from each resolver's own PATH probe —
// it never consulted the registry's `enabled` flag at all. Installed AND enabled must
// both hold for `isCliAvailable()` (the client-side gate every one of those surfaces
// reads) to read true.
vi.mocked(isCodexAvailable).mockReturnValue(true);
vi.mocked(isClaudeAvailable).mockReturnValue(true);
const path = dataPath('clis.json');
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify({ clis: { codex: { enabled: false } } }, null, 2), { mode: 0o600 });
reloadCliRegistry();
try {
const { server } = makeServer({});
const html = await render(server);
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
expect(flags.codex).toBe(false); // installed, but disabled in the registry
expect(flags.claude).toBe(true); // installed and enabled — unaffected by codex's override
} finally {
writeFileSync(path, JSON.stringify({ clis: {} }, null, 2), { mode: 0o600 });
reloadCliRegistry();
}
});
it('injects the full registry catalog for every launch surface, including disabled entries', async () => {
const { server } = makeServer({});
const html = await render(server);
const catalog = JSON.parse(html.match(/window\.__codemanCliCatalog=(\[.*?\]);/)![1]) as Array<{
id: string;
label: string;
shortBadge: string;
order: number;
kind: string;
enabled: boolean;
available: boolean;
}>;
expect(catalog.map((entry) => entry.id)).toEqual(STOCK_CLIS.map((entry) => entry.id));
expect(catalog.find((entry) => entry.id === 'codex')).toMatchObject({ label: 'Codex', kind: 'agent' });
expect(catalog.find((entry) => entry.id === 'shell')).toMatchObject({ enabled: true, available: true });
expect(
catalog.every((entry) =>
Object.keys(entry).every((key) =>
['id', 'label', 'shortBadge', 'order', 'kind', 'enabled', 'available'].includes(key)
)
)
).toBe(true);
});
it('reports which run modes the custom-model Run-menu picker may generate an entry for', async () => {
// Read generically off the CLI registry's own capabilities, not a hardcoded id
// list — antigravity (`unsupported`) and shell (`kind !== 'agent'`) must be
@@ -297,7 +350,7 @@ describe('WebServer.renderIndexHtml', () => {
const html = await render(server);
expect(html).toContain('window.__codemanCliAvailable=');
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
expect(Object.values(flags).every((v) => v === false)).toBe(true);
expect(Object.entries(flags).every(([key, value]) => key === 'shell' || value === false)).toBe(true);
});
it('skips the probe for a solo window, which has no welcome screen or run menu', async () => {
@@ -305,6 +358,7 @@ describe('WebServer.renderIndexHtml', () => {
const { server } = makeServer({});
const html = await render(server, 'sess-123');
expect(html).not.toContain('__codemanCliAvailable');
expect(html).not.toContain('__codemanCliCatalog');
expect(html).not.toContain('__codemanCustomModelClis');
});
+1 -1
View File
@@ -183,7 +183,7 @@ Integration tests that spawn web servers use unique ports to avoid conflicts.
| Port | Test File | Notes |
|-------|------------------------------|----------------------------------|
| 3099 | quick-start.test.ts | Basic startup tests |
| 3299 | quick-start.test.ts | Basic startup tests |
| 3102 | session.test.ts | Session lifecycle tests |
| 3105 | scheduled-runs.test.ts | Scheduled task tests |
| 3107 | sse-events.test.ts | Server-Sent Events tests |
+771
View File
@@ -0,0 +1,771 @@
/**
* @fileoverview Route tests for /api/clis (docs/cli-enable-disable-plan.md, Phases 2-5).
* Mirrors the admin-gating test shape used for other admin/settings surfaces (see
* test/routes/search-routes.test.ts's multi-user block).
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME, not one per `it()` — a write in
* one test is visible to every test declared after it. Phase 3-5 tests therefore each
* clean up what they create (delete a custom entry, restore a toggled stock flag) so
* later tests, including the Phase 2 "every entry is stock" assumption above, still hold.
*
* Port: N/A (app.inject(), no live server).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { dirname } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { installEnv, registerCliRegistryRoutes, type CliListItem } from '../../src/web/routes/cli-registry-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { CreateSessionSchema } from '../../src/web/schemas.js';
import { buildSpawnCommandFromRegistry } from '../../src/session-cli-registry-bridge.js';
import { defaultRemoteCommandForMode } from '../../src/remote-hosts.js';
import { defaultDockerCommandForMode } from '../../src/docker-hosts.js';
import {
getCli,
registryFilePath,
reloadCliRegistry,
resolveInstallCommandForPlatform,
} from '../../src/config/cli-registry/registry.js';
// The install route spawns a real shell command, so `spawn` is replaced with a fake
// child (every other child_process export stays real). The two cache invalidators are
// wrapped pass-through spies: the real invalidation still runs, and the tests can see
// WHICH binaries and id each route forgot.
const { spawnMock, invalidateBinariesSpy, invalidateIdSpy } = vi.hoisted(() => ({
spawnMock: vi.fn(),
invalidateBinariesSpy: vi.fn(),
invalidateIdSpy: vi.fn(),
}));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, spawn: spawnMock };
});
vi.mock('../../src/utils/cli-executable-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-executable-resolver.js')>();
return {
...actual,
invalidateCliExecutableResolvers: (binaries: readonly string[]) => {
invalidateBinariesSpy([...binaries]);
actual.invalidateCliExecutableResolvers(binaries);
},
};
});
vi.mock('../../src/utils/cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-resolver.js')>();
return {
...actual,
invalidateCliResolverCache: (id?: string) => {
invalidateIdSpy(id);
actual.invalidateCliResolverCache(id);
},
};
});
/** A spawned install that prints one line and exits with `code` on the next tick. */
function fakeInstallChild(code: number): EventEmitter {
const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
const stdout = new EventEmitter();
child.stdout = stdout;
child.stderr = new EventEmitter();
child.kill = vi.fn();
setImmediate(() => {
stdout.emit('data', Buffer.from(code === 0 ? 'installed\n' : 'boom\n'));
child.emit('close', code);
});
return child;
}
/** Every write endpoint requires this on; toggled per-test by writing settings.json directly. */
function enableCliManagement(): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: true }));
}
/**
* The inverse, and load-bearing for every "off" test below: settings.json is shared by
* the whole FILE (one temp HOME, not one per `it()`), so a "should be rejected while off"
* test cannot assume the flag started false — an EARLIER test may have called
* `enableCliManagement()` and left it on.
*/
function disableCliManagement(): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: false }));
}
describe('GET /api/clis', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('single-user mode: returns every registry entry, disabled stock CLIs included', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'GET', url: '/api/clis' });
expect(res.statusCode).toBe(200);
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.success).toBe(true);
const ids = body.data.map((c) => c.id);
expect(ids).toContain('claude');
expect(ids).toContain('shell');
expect(ids.length).toBeGreaterThanOrEqual(9);
});
it('every item has the expected shape and excludes spawn-time fields', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'GET', url: '/api/clis' });
const body = res.json() as { success: true; data: CliListItem[] };
for (const cli of body.data) {
expect(typeof cli.id).toBe('string');
expect(typeof cli.label).toBe('string');
expect(typeof cli.shortBadge).toBe('string');
expect(typeof cli.order).toBe('number');
expect(['agent', 'shell']).toContain(cli.kind);
expect(typeof cli.enabled).toBe('boolean');
expect(typeof cli.stock).toBe('boolean');
expect(typeof cli.installed).toBe('boolean');
expect(cli).not.toHaveProperty('launch');
expect(cli).not.toHaveProperty('env');
expect(cli).not.toHaveProperty('capabilities');
expect(cli).not.toHaveProperty('overlays');
expect(cli).not.toHaveProperty('discovery');
}
});
it('every entry is stock: true (no custom entries exist before Phase 5)', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'GET', url: '/api/clis' });
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.data.every((c) => c.stock === true)).toBe(true);
});
it('multi-user mode: an admin sees the full list', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'root', role: 'admin' },
});
const res = await app.inject({ method: 'GET', url: '/api/clis' });
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.data.length).toBeGreaterThanOrEqual(9);
});
it('multi-user mode: a non-admin sees an empty list, not a 403', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'GET', url: '/api/clis' });
expect(res.statusCode).toBe(200);
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.data).toEqual([]);
});
});
describe('PUT /api/clis/:id (Phase 3: enable/disable)', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('rejects when cliManagementEnabled is off — no settings.json write at all', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(res.statusCode).toBe(403);
const body = res.json() as { errorCode: string };
expect(body.errorCode).toBe('FORBIDDEN');
});
it('toggles a stock CLI off then back on, visible with no reload needed', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const off = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(off.statusCode).toBe(200);
const afterOff = await app.inject({ method: 'GET', url: '/api/clis' });
const grokOff = (afterOff.json() as { data: CliListItem[] }).data.find((c) => c.id === 'grok');
expect(grokOff?.enabled).toBe(false);
const on = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
expect(on.statusCode).toBe(200);
const afterOn = await app.inject({ method: 'GET', url: '/api/clis' });
const grokOn = (afterOn.json() as { data: CliListItem[] }).data.find((c) => c.id === 'grok');
expect(grokOn?.enabled).toBe(true);
});
it('rejects disabling shell, changes nothing', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/shell', payload: { enabled: false } });
// errorCode, not statusCode: this branch returns bare createErrorResponse()
// and relies on server.ts's global preSerialization hook to map it to 400,
// which the lightweight test harness does not register — same convention
// as test/routes/custom-model-routes.test.ts's equivalent checks.
expect(res.json().errorCode).toBe('INVALID_INPUT');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'shell');
expect(entry?.enabled).toBe(true);
});
it('allows disabling claude — only shell keeps the hard guarantee (revised 2026-09-23)', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const off = await app.inject({ method: 'PUT', url: '/api/clis/claude', payload: { enabled: false } });
expect(off.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'claude');
expect(entry?.enabled).toBe(false);
// Restore for any later test in this file that assumes claude's stock default.
await app.inject({ method: 'PUT', url: '/api/clis/claude', payload: { enabled: true } });
});
it('404s an id that does not exist, never creating one', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/nonexistent-id', payload: { enabled: true } });
expect(res.json().errorCode).toBe('NOT_FOUND');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'nonexistent-id')).toBe(false);
});
it('multi-user: non-admin is rejected before the write', async () => {
enableCliManagement();
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(res.statusCode).toBe(403);
});
it('preserves an unrelated existing override key on a stock entry when toggling enabled', async () => {
enableCliManagement();
// grok's real accent is #f43f5e (stock.ts); overriding it here first proves
// the enabled-only write is a MERGE, not a replace, of that id's override.
mkdirSync(dirname(registryFilePath()), { recursive: true });
writeFileSync(registryFilePath(), JSON.stringify({ schemaVersion: 1, clis: { grok: { accent: '#123456' } } }), {
mode: 0o600,
});
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(res.statusCode).toBe(200);
// GET /api/clis deliberately excludes `accent` (Phase 2's own response
// shape), so verify the merge server-side through the registry itself.
const grok = getCli('grok');
expect(grok?.enabled).toBe(false);
expect(grok?.accent).toBe('#123456');
// Restore for any later test in this file that assumes grok's stock default.
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
});
it('writes clis.json mode 0600 on POSIX', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
if (process.platform !== 'win32') {
const mode = statSync(registryFilePath()).mode & 0o777;
expect(mode).toBe(0o600);
}
});
});
describe('POST /api/clis/:id/install (Phase 4)', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('rejects when cliManagementEnabled is off', async () => {
disableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.statusCode).toBe(403);
});
it('rejects a custom entry id — Decision 3: a custom install command is never executed', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-install-guard', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
const res = await app.inject({ method: 'POST', url: '/api/clis/test-install-guard/install' });
expect(res.json().errorCode).toBe('INVALID_INPUT');
await app.inject({ method: 'DELETE', url: '/api/clis/test-install-guard' });
});
it('multi-user: non-admin is rejected before any spawn', async () => {
enableCliManagement();
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.statusCode).toBe(403);
});
});
describe('Custom CLI entries (Phase 5)', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('rejects create when cliManagementEnabled is off', async () => {
disableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-off', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(res.statusCode).toBe(403);
});
it('creates a custom entry, it appears in GET /api/clis with stock:false', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const create = await app.inject({
method: 'POST',
url: '/api/clis',
payload: {
id: 'test-create',
label: 'Test CLI',
shortBadge: 'TC',
binaries: ['test-create-bin'],
argv: ['test-create-bin', '--flag'],
},
});
expect(create.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'test-create');
expect(entry?.stock).toBe(false);
expect(entry?.label).toBe('Test CLI');
await app.inject({ method: 'DELETE', url: '/api/clis/test-create' });
});
it('rejects a create whose id collides with a stock id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'claude', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
});
it('rejects creating the same custom id twice', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const payload = { id: 'test-dup', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] };
const first = await app.inject({ method: 'POST', url: '/api/clis', payload });
expect(first.statusCode).toBe(200);
const second = await app.inject({ method: 'POST', url: '/api/clis', payload });
expect(second.json().errorCode).toBe('ALREADY_EXISTS');
await app.inject({ method: 'DELETE', url: '/api/clis/test-dup' });
});
it('rejects a literal with shell metacharacters (the schema, not a new bypass)', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-unsafe', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x; rm -rf /'] },
});
expect(res.statusCode).toBe(400);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'test-unsafe')).toBe(false);
});
it('updates an existing custom entry via PUT /api/clis/custom/:id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-update', label: 'Before', shortBadge: 'BE', binaries: ['x'], argv: ['x'] },
});
const update = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-update',
payload: { label: 'After', shortBadge: 'AF', binaries: ['y'], argv: ['y', '--z'] },
});
expect(update.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'test-update');
expect(entry?.label).toBe('After');
await app.inject({ method: 'DELETE', url: '/api/clis/test-update' });
});
it('rejects PUT /api/clis/custom/:id against a stock id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'PUT',
url: '/api/clis/custom/claude',
payload: { label: 'Hijack', shortBadge: 'HJ', binaries: ['x'], argv: ['x'] },
});
expect(res.json().errorCode).toBe('INVALID_INPUT');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'claude')?.label).toBe('Claude Code');
});
it('404s an update against a custom id that does not exist', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'PUT',
url: '/api/clis/custom/nonexistent-custom',
payload: { label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(res.json().errorCode).toBe('NOT_FOUND');
});
it('deletes a custom entry; a second delete 404s', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-delete', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
const del = await app.inject({ method: 'DELETE', url: '/api/clis/test-delete' });
expect(del.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'test-delete')).toBe(false);
const again = await app.inject({ method: 'DELETE', url: '/api/clis/test-delete' });
expect(again.json().errorCode).toBe('NOT_FOUND');
});
it('refuses to delete a stock CLI', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'DELETE', url: '/api/clis/claude' });
expect(res.json().errorCode).toBe('INVALID_INPUT');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'claude')).toBe(true);
});
it('multi-user: non-admin is rejected on create/update/delete', async () => {
enableCliManagement();
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const create = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-mu', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(create.statusCode).toBe(403);
const update = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-mu',
payload: { label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(update.statusCode).toBe(403);
const del = await app.inject({ method: 'DELETE', url: '/api/clis/test-mu' });
expect(del.statusCode).toBe(403);
});
it('a custom entry can also be toggled via the simple Phase 3 endpoint', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-toggle', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'], enabled: true },
});
const off = await app.inject({ method: 'PUT', url: '/api/clis/test-toggle', payload: { enabled: false } });
expect(off.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'test-toggle');
expect(entry?.enabled).toBe(false);
// The rest of the entry (binaries/argv/label) must survive the shallow
// enabled-only merge — proven indirectly: a second full update still finds
// the row and changes its label, which would fail if the toggle had
// corrupted the stored shape.
const relabel = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-toggle',
payload: { label: 'Still here', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(relabel.statusCode).toBe(200);
await app.inject({ method: 'DELETE', url: '/api/clis/test-toggle' });
});
});
describe('resolver caches are forgotten when what a CLI resolves to changes', () => {
beforeEach(() => {
spawnMock.mockReset();
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
});
it('GET /api/clis names the install command for a stock entry only (for the confirm dialog)', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-cmd', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
const list = (await app.inject({ method: 'GET', url: '/api/clis' })).json() as { data: CliListItem[] };
const grok = list.data.find((c) => c.id === 'grok');
expect(grok?.installCommand).toBe(resolveInstallCommandForPlatform(getCli('grok')!));
expect(list.data.find((c) => c.id === 'test-cmd')).not.toHaveProperty('installCommand');
await app.inject({ method: 'DELETE', url: '/api/clis/test-cmd' });
});
it('a successful install runs the stock command and forgets that CLI’s cached lookups', async () => {
enableCliManagement();
spawnMock.mockImplementation(() => fakeInstallChild(0));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.statusCode).toBe(200);
const grok = getCli('grok')!;
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe(resolveInstallCommandForPlatform(grok));
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, detached: true });
// Without this, the Run menu and a session spawn replayed the pre-install miss
// for up to the 5-minute negative-cache backoff.
expect(invalidateBinariesSpy).toHaveBeenCalledWith(grok.discovery.binaries);
expect(invalidateIdSpy).toHaveBeenCalledWith('grok');
});
it('a failed install still forgets the cached lookups (it may have left a binary behind)', async () => {
enableCliManagement();
spawnMock.mockImplementation(() => fakeInstallChild(1));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.json().errorCode).toBe('OPERATION_FAILED');
expect(invalidateIdSpy).toHaveBeenCalledWith('grok');
});
it('never spawns anything for a custom entry, even though the route exists', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-nospawn', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
await app.inject({ method: 'POST', url: '/api/clis/test-nospawn/install' });
expect(spawnMock).not.toHaveBeenCalled();
await app.inject({ method: 'DELETE', url: '/api/clis/test-nospawn' });
});
it('editing a custom entry forgets BOTH its old and new binaries, and its id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-rebin', label: 'X', shortBadge: 'X', binaries: ['old-bin'], argv: ['old-bin'] },
});
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
const res = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-rebin',
payload: { label: 'X', shortBadge: 'X', binaries: ['new-bin'], argv: ['new-bin'] },
});
expect(res.statusCode).toBe(200);
expect(invalidateBinariesSpy).toHaveBeenCalledWith(['old-bin', 'new-bin']);
expect(invalidateIdSpy).toHaveBeenCalledWith('test-rebin');
await app.inject({ method: 'DELETE', url: '/api/clis/test-rebin' });
});
it('deleting a custom entry forgets its binaries and id, so a same-named re-create starts clean', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-forget', label: 'X', shortBadge: 'X', binaries: ['gone-bin'], argv: ['gone-bin'] },
});
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
await app.inject({ method: 'DELETE', url: '/api/clis/test-forget' });
expect(invalidateBinariesSpy).toHaveBeenCalledWith(['gone-bin']);
expect(invalidateIdSpy).toHaveBeenCalledWith('test-forget');
});
});
/**
* The #476 review's must-fix items for the writer. Each reproduces the failure it reported:
* a corrupt hand-edit overwritten by one toggle, parallel toggles lost to a shared temp file,
* and a file the reader refuses rewritten as trusted 0600 config.
*/
describe('registry writes are serialized and never clobber a file the reader would refuse', () => {
beforeEach(() => {
spawnMock.mockReset();
});
/** Put the override file back to "absent" so later tests start from stock. */
function clearRegistryFile(): void {
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
}
it('refuses to overwrite a clis.json that does not parse, and leaves it untouched', async () => {
enableCliManagement();
mkdirSync(dirname(registryFilePath()), { recursive: true });
const handEdit = '{ "schemaVersion": 1, "clis": { "grok": { "accent": "#123456" }, }';
writeFileSync(registryFilePath(), handEdit, { mode: 0o600 });
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/pi', payload: { enabled: false } });
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('not valid JSON');
expect(readFileSync(registryFilePath(), 'utf-8')).toBe(handEdit);
clearRegistryFile();
});
it.skipIf(process.platform === 'win32')(
'refuses to rewrite a clis.json with group/world permission bits, naming the chmod fix',
async () => {
enableCliManagement();
mkdirSync(dirname(registryFilePath()), { recursive: true });
writeFileSync(registryFilePath(), JSON.stringify({ schemaVersion: 1, clis: {} }));
chmodSync(registryFilePath(), 0o644);
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/pi', payload: { enabled: false } });
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('chmod 600');
// Still the refused mode: the write did not turn it into trusted config.
expect(statSync(registryFilePath()).mode & 0o777).toBe(0o644);
clearRegistryFile();
}
);
it('keeps every one of several parallel toggles, with no failures', async () => {
enableCliManagement();
clearRegistryFile();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const ids = ['grok', 'pi', 'omp', 'gemini'];
const results = await Promise.all(
ids.map((id) => app.inject({ method: 'PUT', url: `/api/clis/${id}`, payload: { enabled: false } }))
);
expect(results.map((r) => r.statusCode)).toEqual(ids.map(() => 200));
const onDisk = JSON.parse(readFileSync(registryFilePath(), 'utf-8')) as {
clis: Record<string, { enabled?: boolean }>;
};
for (const id of ids) {
expect(onDisk.clis[id]?.enabled).toBe(false);
expect(getCli(id)?.enabled).toBe(false);
}
clearRegistryFile();
});
it('editing a disabled custom entry keeps it disabled when the body omits enabled', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-keep-off', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
await app.inject({ method: 'PUT', url: '/api/clis/test-keep-off', payload: { enabled: false } });
const update = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-keep-off',
payload: { label: 'Renamed', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(update.statusCode).toBe(200);
expect(getCli('test-keep-off')?.enabled).toBe(false);
expect(getCli('test-keep-off')?.label).toBe('Renamed');
await app.inject({ method: 'DELETE', url: '/api/clis/test-keep-off' });
});
it('answers 409 to a second install of the same CLI while the first is still running', async () => {
enableCliManagement();
let finish: (code: number) => void = () => {};
spawnMock.mockImplementation(() => {
const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
finish = (code) => child.emit('close', code);
return child;
});
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const first = app.inject({ method: 'POST', url: '/api/clis/grok/install' });
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
const second = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(second.json().errorCode).toBe('CONFLICT');
expect(spawnMock).toHaveBeenCalledTimes(1);
finish(0);
expect((await first).statusCode).toBe(200);
// The guard is released once the first finishes.
spawnMock.mockImplementation(() => fakeInstallChild(0));
const third = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(third.statusCode).toBe(200);
});
it('hands the install script an environment with every CODEMAN_* variable stripped', async () => {
enableCliManagement();
process.env.CODEMAN_TEST_SECRET = 'do-not-leak';
try {
spawnMock.mockImplementation(() => fakeInstallChild(0));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
const env = spawnMock.mock.calls[0][1].env as NodeJS.ProcessEnv;
expect(Object.keys(env).filter((k) => k.startsWith('CODEMAN_'))).toEqual([]);
expect(env.PATH).toBe(process.env.PATH);
} finally {
delete process.env.CODEMAN_TEST_SECRET;
}
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
});
});
/**
* #343 review, finding 2: the run-mode allowlist used to be computed once at import, so a
* CLI toggled on in Settings still failed POST /api/sessions with INVALID_INPUT until a
* restart. Drives the real toggle/create routes and then the real session-create schema.
*/
describe('a toggle or new custom CLI reaches session-create validation with no restart', () => {
it('disabling grok rejects mode grok at once, and re-enabling accepts it again', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
expect(CreateSessionSchema.safeParse({ mode: 'grok' }).success).toBe(true);
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(CreateSessionSchema.safeParse({ mode: 'grok' }).success).toBe(false);
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
expect(CreateSessionSchema.safeParse({ mode: 'grok' }).success).toBe(true);
});
it('a newly created custom CLI is a valid mode immediately, and stops being one when deleted', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
expect(CreateSessionSchema.safeParse({ mode: 'test-live-mode' }).success).toBe(false);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-live-mode', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(CreateSessionSchema.safeParse({ mode: 'test-live-mode' }).success).toBe(true);
await app.inject({ method: 'DELETE', url: '/api/clis/test-live-mode' });
expect(CreateSessionSchema.safeParse({ mode: 'test-live-mode' }).success).toBe(false);
});
});
/**
* #347 review, finding 5: a custom CLI was API-acceptable but not survivable downstream (a
* remote pane command came out as `cd <path> && undefined`). #476 makes custom entries
* creatable from Settings, so pin that one created here launches everywhere it can run.
*/
describe('a custom CLI created through the API launches locally, over ssh and in docker', () => {
it('renders its argv locally and its binary for the remote/docker overlays', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-launch', label: 'X', shortBadge: 'X', binaries: ['my-agent'], argv: ['my-agent', '--yolo'] },
});
const entry = getCli('test-launch')!;
const mode = 'test-launch' as Parameters<typeof defaultRemoteCommandForMode>[0];
expect(buildSpawnCommandFromRegistry(entry, { mode, sessionId: 'sid' })).toBe('my-agent --yolo');
expect(defaultRemoteCommandForMode(mode)).toContain('my-agent');
expect(defaultRemoteCommandForMode(mode)).not.toContain('undefined');
expect(defaultDockerCommandForMode(mode)).toBe('exec my-agent');
await app.inject({ method: 'DELETE', url: '/api/clis/test-launch' });
});
});
+90
View File
@@ -5,10 +5,16 @@
* auto-naming can never overwrite a name a person chose, on this server or
* on the one that restores the session after a restart.
*
* The rename also reaches Claude's own `/resume` title: a `custom-title` row is
* appended to the conversation's transcript, the row `/rename` writes.
*
* Uses app.inject() — no real HTTP ports needed.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { Session } from '../../src/session.js';
@@ -18,17 +24,45 @@ describe('PUT /api/sessions/:id/name', () => {
let harness: RouteTestHarness;
let session: Session;
const updateSessionName = vi.fn(() => true);
const transcriptDir = mkdtempSync(join(tmpdir(), 'codeman-rename-title-'));
const transcriptPath = join(transcriptDir, '6f1c1a2e-0000-4000-8000-000000000001.jsonl');
const transcriptRows = () =>
readFileSync(transcriptPath, 'utf8')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, unknown>);
beforeAll(async () => {
writeFileSync(transcriptPath, `${JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } })}\n`);
harness = await createRouteTestHarness(registerSessionRoutes);
// A REAL session, since the ownership flag lives on the class, not the mock.
session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' });
harness.ctx.sessions.set(session.id, session as never);
(harness.ctx.mux as Record<string, unknown>).updateSessionName = updateSessionName;
(harness.ctx as Record<string, unknown>).getTranscriptPath = (id: string) =>
id === session.id ? transcriptPath : null;
});
afterAll(async () => {
await harness.app.close();
rmSync(transcriptDir, { recursive: true, force: true });
});
it('treats a same-name PUT as a no-op: stays placeholder, writes no title row', async () => {
// The Session Options field saves on blur and recomposes the unchanged placeholder.
const before = transcriptRows().length;
const res = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${session.id}/name`,
payload: { name: 'w1-demo' },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ name: 'w1-demo' });
expect(session.nameSource).toBe('placeholder');
expect(transcriptRows()).toHaveLength(before);
expect(updateSessionName).not.toHaveBeenCalled();
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => {
@@ -57,4 +91,60 @@ describe('PUT /api/sessions/:id/name', () => {
// What the restore path will read back: the persisted state carries the flag.
expect(session.toState().nameSource).toBe('manual');
});
it("appends the name as the conversation's custom-title, the row /resume reads", async () => {
const res = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${session.id}/name`,
payload: { name: '修复登录跳转' },
});
expect(res.statusCode).toBe(200);
expect(transcriptRows().at(-1)).toEqual({
type: 'custom-title',
customTitle: '修复登录跳转',
sessionId: '6f1c1a2e-0000-4000-8000-000000000001',
});
});
it('writes no title row for an empty name, which would blank the /resume entry', async () => {
const before = transcriptRows().length;
const res = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${session.id}/name`,
payload: { name: ' ' },
});
expect(res.statusCode).toBe(200);
expect(transcriptRows()).toHaveLength(before);
});
it('appends a title row only once when the same name is PUT twice', async () => {
const before = transcriptRows().length;
for (let i = 0; i < 2; i++) {
await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${session.id}/name`,
payload: { name: 'twice' },
});
}
expect(transcriptRows()).toHaveLength(before + 1);
});
it('writes no title row for a docker session, whose transcript lives in the container', async () => {
const before = transcriptRows().length;
Object.defineProperty(session, 'docker', { configurable: true, get: () => ({ caseName: 'c' }) });
try {
const res = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${session.id}/name`,
payload: { name: 'in a container' },
});
expect(res.statusCode).toBe(200);
expect(session.name).toBe('in a container');
expect(transcriptRows()).toHaveLength(before);
} finally {
delete (session as unknown as Record<string, unknown>).docker;
}
});
});
+16
View File
@@ -282,6 +282,22 @@ describe('ws-routes', () => {
}
});
it('refuses an oversized sequenced frame with an error ACK, so the client can drop it', async () => {
// Issue #484: a silent return left the frame unACKed, and the client's
// durable queue re-sent it every few seconds forever.
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
const hugeInput = 'y'.repeat(MAX_INPUT_LENGTH + 1);
ws.send(JSON.stringify({ t: 'i', d: hugeInput, cid: 'c1', seq: 3 }));
expect(await nextMessage(ws)).toEqual({ t: 'ia', seq: 3, err: 'too_large', max: MAX_INPUT_LENGTH });
expect(session.writeBuffer).not.toContain(hugeInput);
} finally {
ws.close();
}
});
it('ignores malformed JSON messages', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
+14 -3
View File
@@ -39,7 +39,7 @@ interface Harness {
externalIds: string[];
}
function loadHarness(): Harness {
function loadHarness(catalog: Array<{ id: string; kind: string; enabled: boolean }> = []): Harness {
const dom = new JSDOM('<!doctype html><body><button id="runBtn"></button></body>', {
url: 'http://localhost/',
runScripts: 'dangerously',
@@ -49,7 +49,9 @@ function loadHarness(): Harness {
document: Document;
CodemanApp: new () => HarnessApp;
__TEST_RUN_MODE_LAUNCH: Record<string, unknown>;
__codemanCliCatalog: Array<{ id: string; kind: string; enabled: boolean }>;
};
win.__codemanCliCatalog = catalog;
win.eval('window.CodemanApp = function CodemanApp() {};');
// The assignment rides in the SAME evaluated string as the module:
// RUN_MODE_LAUNCH is a bare top-level `const`, visible only to this eval call
@@ -106,9 +108,18 @@ describe('run() dispatch (session-ui.js)', () => {
});
}
it('an enabled registry agent reaches the shared launcher', async () => {
const { app } = loadHarness([{ id: 'custom-agent', kind: 'agent', enabled: true }]);
app._runMode = 'custom-agent';
await expect(app.run()).resolves.toBe('cli:custom-agent');
expect(app._runCliMode).toHaveBeenCalledTimes(1);
expect(app._runCliMode).toHaveBeenCalledWith('custom-agent');
expect(app.runClaude).not.toHaveBeenCalled();
});
it('an unknown mode lands on runClaude(), never on the shared launcher', async () => {
// `_runCliMode(mode)` reads `RUN_MODE_LAUNCH[mode].label` unguarded, so an
// unknown id reaching it would throw rather than launch anything.
// A stale localStorage mode must not reach `_runCliMode()`: it has neither
// a stock launch shape nor an enabled registry entry to launch.
for (const mode of ['nope', 'CLAUDE', 'code x']) {
const { app } = loadHarness();
app._runMode = mode;
+106 -75
View File
@@ -376,47 +376,84 @@ describe('Codex quick start settings', () => {
});
describe('CLI availability gating (#200/#201)', () => {
// Drives the REAL settings-ui.js + session-ui.js against stub elements, so an
// added run mode that nobody wires up here is what these are meant to catch.
function loadUi(flags: Record<string, boolean> | undefined) {
const CATALOG = [
{ id: 'claude', label: 'Claude Code', shortBadge: 'CC', kind: 'agent', enabled: true },
{ id: 'opencode', label: 'OpenCode', shortBadge: 'OC', kind: 'agent', enabled: true },
{ id: 'codex', label: 'Codex', shortBadge: 'CX', kind: 'agent', enabled: true },
{ id: 'gemini', label: 'Gemini', shortBadge: 'GM', kind: 'agent', enabled: true },
{ id: 'antigravity', label: 'Antigravity', shortBadge: 'AG', kind: 'agent', enabled: true },
{ id: 'pi', label: 'Pi', shortBadge: 'PI', kind: 'agent', enabled: true },
{ id: 'grok', label: 'Grok', shortBadge: 'GK', kind: 'agent', enabled: true },
{ id: 'deepseek', label: 'DeepSeek', shortBadge: 'DS', kind: 'agent', enabled: true },
{ id: 'omp', label: 'OMP', shortBadge: 'OM', kind: 'agent', enabled: true },
{ id: 'custom-agent', label: 'Custom Agent', shortBadge: 'CA', kind: 'agent', enabled: true },
{ id: 'shell', label: 'Shell', shortBadge: 'SH', kind: 'shell', enabled: true },
];
function element() {
const el: any = {
style: { display: 'PRISTINE' },
dataset: {},
children: [] as any[],
setAttribute: () => {},
appendChild(child: any) {
this.children.push(child);
return child;
},
append(child: any) {
this.children.push(child);
},
replaceChildren(...children: any[]) {
this.children = children;
},
};
return el;
}
// Drives the REAL settings-ui.js + session-ui.js against stub elements, including
// a custom registry entry so a static stock-only list cannot pass this test.
function loadUi(flags: Record<string, boolean> | undefined, catalog = CATALOG) {
const CodemanApp = function CodemanApp(this: any) {};
const welcomeBtns: Record<string, { style: { display: string } }> = {};
for (const id of [
'welcomeClaudeBtn',
'welcomeOpencodeBtn',
'welcomeAntigravityBtn',
'welcomeGeminiBtn',
'welcomePiBtn',
'welcomeGrokBtn',
'welcomeOmpBtn',
'welcomeTunnelBtn',
]) {
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
}
const modeBtns: Record<string, { style: { display: string } }> = {};
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'omp', 'shell']) {
modeBtns[mode] = { style: { display: 'PRISTINE' } };
const welcomeCliActions = element();
const tunnelBtn = element();
const runModeCliOptions = element();
const modeBtns: Record<string, any> = {};
for (const cli of catalog) {
modeBtns[cli.id] = element();
modeBtns[cli.id].dataset.mode = cli.id;
}
const menu = {
querySelector: (sel: string) => {
const m = sel.match(/data-mode="([^"]+)"/);
return m ? (modeBtns[m[1]] ?? null) : null;
},
querySelector: (sel: string) =>
sel === '#runModeDeepSeekInstall' || sel === '#runModeDeepSeekWeb' ? null : null,
querySelectorAll: () => Object.values(modeBtns),
};
const context: any = vm.createContext({
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => welcomeBtns[id] ?? null, querySelector: () => null },
document: {
getElementById: (id: string) =>
id === 'welcomeCliActions'
? welcomeCliActions
: id === 'welcomeTunnelBtn'
? tunnelBtn
: id === 'runModeCliOptions'
? runModeCliOptions
: null,
createElement: element,
createElementNS: element,
querySelector: () => null,
},
console,
});
context.window = context;
context.__codemanCliCatalog = catalog;
if (flags !== undefined) context.__codemanCliAvailable = flags;
for (const file of ['settings-ui.js', 'session-ui.js']) {
const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
vm.runInContext(src, context, { filename: file });
}
return { app: new (CodemanApp as any)(), welcomeBtns, modeBtns, menu };
return { app: new (CodemanApp as any)(), welcomeCliActions, tunnelBtn, runModeCliOptions, modeBtns, menu };
}
const ALL_OFF = {
@@ -427,50 +464,28 @@ describe('Codex quick start settings', () => {
antigravity: false,
pi: false,
grok: false,
deepseek: false,
omp: false,
'custom-agent': false,
cloudflared: false,
};
it('hides each welcome button whose tool is missing, including the tunnel', () => {
const { app, welcomeBtns } = loadUi({ ...ALL_OFF, claude: true });
it('renders only enabled and available registry entries on the welcome screen', () => {
const { app, welcomeCliActions, tunnelBtn } = loadUi({ ...ALL_OFF, claude: true, 'custom-agent': true });
app.applyWelcomeCliVisibility();
expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
expect(welcomeBtns.welcomeOpencodeBtn.style.display).toBe('none');
expect(welcomeBtns.welcomeAntigravityBtn.style.display).toBe('none');
expect(welcomeBtns.welcomeGeminiBtn.style.display).toBe('none');
const offered = welcomeCliActions.children.map((btn: any) => btn.dataset.mode);
expect(offered).toEqual(['claude', 'custom-agent', 'shell']);
// #200 originally DELETED the tunnel button and its QR outright; it is gated
// on cloudflared instead, so a box that has cloudflared keeps the feature.
expect(welcomeBtns.welcomeTunnelBtn.style.display).toBe('none');
expect(tunnelBtn.style.display).toBe('none');
const withTunnel = loadUi({ ...ALL_OFF, cloudflared: true });
withTunnel.app.applyWelcomeCliVisibility();
expect(withTunnel.welcomeBtns.welcomeTunnelBtn.style.display).toBe('flex');
expect(withTunnel.tunnelBtn.style.display).toBe('flex');
// Pi is gated on `pi` like the rest; the resolver additionally version-probes
// the binary, so a stray `pi` on PATH reports unavailable rather than broken.
const withPi = loadUi({ ...ALL_OFF, pi: true });
withPi.app.applyWelcomeCliVisibility();
expect(withPi.welcomeBtns.welcomePiBtn.style.display).toBe('flex');
// Grok is gated on `grok` like the rest; the resolver additionally
// version-probes the binary, so a stray `grok` on PATH reports unavailable.
const withGrok = loadUi({ ...ALL_OFF, grok: true });
withGrok.app.applyWelcomeCliVisibility();
expect(withGrok.welcomeBtns.welcomeGrokBtn.style.display).toBe('flex');
expect(withGrok.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
expect(withPi.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
// Antigravity is a first-class welcome action, gated on `agy` like the rest.
const withAgy = loadUi({ ...ALL_OFF, antigravity: true });
withAgy.app.applyWelcomeCliVisibility();
expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex');
expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
// OMP is a first-class welcome action, gated on `omp` like the rest.
const withOmp = loadUi({ ...ALL_OFF, omp: true });
withOmp.app.applyWelcomeCliVisibility();
expect(withOmp.welcomeBtns.welcomeOmpBtn.style.display).toBe('flex');
expect(withOmp.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
expect(withPi.welcomeCliActions.children.map((btn: any) => btn.dataset.mode)).toEqual(['pi', 'shell']);
});
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
@@ -487,34 +502,50 @@ describe('Codex quick start settings', () => {
expect(modeBtns.shell.style.display).toBe('PRISTINE');
});
it('gates every mode the run-mode menu actually offers', () => {
// Catches a sixth run mode being added to index.html without being gated,
// which is exactly how antigravity slipped past #201.
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
const menuHtml = html.slice(html.indexOf('id="runModeMenu"'));
const offered = [...menuHtml.slice(0, menuHtml.indexOf('</div>')).matchAll(/data-mode="([^"]+)"/g)].map(
(m) => m[1]
it('renders each enabled agent registry entry in the Run menu, including custom entries', () => {
const { app, runModeCliOptions } = loadUi({ ...ALL_OFF, claude: true, 'custom-agent': true });
app.renderRegistryRunOptions();
expect(runModeCliOptions.children.map((btn: any) => btn.dataset.mode)).toContain('custom-agent');
expect(runModeCliOptions.children.map((btn: any) => btn.dataset.mode)).not.toContain('shell');
});
it('labels welcome buttons "Run <label>", the strings i18n.js translates ("Run Claude Code")', () => {
const { app, welcomeCliActions } = loadUi({ ...ALL_OFF, claude: true });
app.applyWelcomeCliVisibility();
const texts = welcomeCliActions.children.map((btn: any) =>
btn.children.filter((c: unknown) => typeof c === 'string').join('')
);
expect(offered).toContain('antigravity');
expect(offered).toContain('pi');
expect(offered).toContain('grok');
expect(offered).toContain('omp');
expect(texts).toEqual(['Run Claude Code', 'Run Shell']);
});
it('falls back to the first ENABLED agent when the chosen run mode is disabled, never a hardcoded claude', () => {
const catalog = CATALOG.map((cli) =>
cli.id === 'claude' || cli.id === 'codex' ? { ...cli, enabled: false } : cli
);
const { app } = loadUi(undefined, catalog);
app.runMode = 'codex';
expect(app.runMode).toBe('opencode');
app.runMode = 'claude';
expect(app.runMode).toBe('opencode');
app.runMode = 'gemini';
expect(app.runMode).toBe('gemini');
});
it('builds the run-mode menu from the registry rather than static markup', () => {
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
expect(html).toContain('id="runModeCliOptions"');
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
const gated = fn.slice(0, fn.indexOf('\n },'));
for (const mode of offered.filter((m) => m !== 'shell')) {
expect(gated).toContain(`'${mode}'`);
}
expect(src).toContain('renderRegistryRunOptions()');
expect(src).not.toContain('data-mode="codex"');
});
it('shows everything when the flags were never injected', () => {
// A cached page from a build without the injection, or a solo popup. Hiding
// every run button on a doubt would leave a working install nothing to click.
const { app, welcomeBtns, modeBtns, menu } = loadUi(undefined);
const { app, welcomeCliActions, modeBtns, menu } = loadUi(undefined);
app.applyWelcomeCliVisibility();
app._refreshRunModeAvailability(menu);
expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
expect(welcomeCliActions.children.map((btn: any) => btn.dataset.mode)).toContain('claude');
expect(modeBtns.gemini.style.display).toBe('flex');
});
});
+38
View File
@@ -15,6 +15,7 @@ import {
isValidReleaseTag,
parseGitHubRepo,
reconcileStatusDecision,
expireStalledStatus,
} from '../src/web/self-update.js';
import type { UpdateStatus } from '../src/types/update.js';
@@ -167,3 +168,40 @@ describe('reconcileStatusDecision (boot handoff state machine)', () => {
expect(reconcileStatusDecision(noTarget, '0.9.4', NOW)).toBeNull();
});
});
describe('expireStalledStatus (runtime staleness backstop)', () => {
const NOW = 1_000_000_000_000;
const MIN = 60 * 1000;
const base = (over: Partial<UpdateStatus>): UpdateStatus => ({
updateId: 'u1',
phase: 'queued',
message: '',
fromVersion: '1.24.7',
toVersion: '1.29.0',
startedAt: NOW - 5_000,
updatedAt: NOW - 5_000,
...over,
});
it('leaves a heartbeating update alone', () => {
expect(
expireStalledStatus(base({ phase: 'installing', startedAt: NOW - 60 * MIN, updatedAt: NOW - 3_000 }), NOW)
).toBeNull();
});
it('fails a status that stopped heartbeating (queued forever: status writes were failing)', () => {
const out = expireStalledStatus(
base({ startedAt: NOW - 8 * 24 * 60 * MIN, updatedAt: NOW - 8 * 24 * 60 * MIN }),
NOW
);
expect(out?.phase).toBe('failed');
expect(out?.error).toContain('queued');
expect(out?.updatedAt).toBe(NOW);
});
it('never touches terminal phases or a missing status', () => {
expect(expireStalledStatus(null, NOW)).toBeNull();
expect(expireStalledStatus(base({ phase: 'completed', updatedAt: NOW - 60 * MIN }), NOW)).toBeNull();
expect(expireStalledStatus(base({ phase: 'failed', updatedAt: NOW - 60 * MIN }), NOW)).toBeNull();
});
});
+4 -3
View File
@@ -96,9 +96,9 @@ describe('WebServer index.html <title> templating (#82)', () => {
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
// .js/.css refs, and injects the CLI-availability flags, the custom-model
// Run-menu picker's CLI list and the transcript-gutter widths before </head>;
// strip all so the title remains the only other change.
// .js/.css refs, and injects the CLI-availability flags, launch catalog,
// custom-model Run-menu picker's CLI list and the transcript-gutter widths
// before </head>; strip all so the title remains the only other change.
//
// The flag strips are what keep this test environment-independent. The
// CLI-availability one used to pass here by luck: that script was injected
@@ -109,6 +109,7 @@ describe('WebServer index.html <title> templating (#82)', () => {
const html = (await render('laptop'))
.replace(/(\.(?:js|css))\?v=[^"]*/g, '$1')
.replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '')
.replace(/<script>window\.__codemanCliCatalog=\[.*?\];<\/script>\n/, '')
.replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '')
// Injected unconditionally as an object keyed by run mode, empty when no
// enabled CLI declares a gutter, so it needs stripping on every machine.
+9
View File
@@ -35,6 +35,12 @@ process.env.HOME = testHome;
process.env.USERPROFILE = testHome;
process.env.VITEST = 'true';
for (const key of Object.keys(process.env)) {
if (key.startsWith('CODEMAN_')) delete process.env[key];
}
// Explicitly document the most consequential inherited settings below. The
// loop above intentionally also catches future container/deployment variables.
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
// Gesture availability changes renderIndexHtml output (injects the
@@ -84,6 +90,9 @@ delete process.env.CLAUDE_CONFIG_DIR;
delete process.env.CODEMAN_INSTANCE;
delete process.env.CODEMAN_DATA_DIR;
delete process.env.CODEMAN_TMUX_SOCKET;
// Docker Compose binds cases outside HOME. Leaving this set makes route tests
// write into the deployment's real case root instead of their temp HOME.
delete process.env.CODEMAN_CASES_PATH;
afterEach(() => {
vi.clearAllMocks();
+5
View File
@@ -35,6 +35,7 @@ const STRIPPED_ENV_VARS: Array<[name: string, why: string]> = [
['CODEMAN_DATA_DIR', 'ABSOLUTE override: bypasses the temp HOME and points the suite at a real data dir'],
['CODEMAN_TMUX_SOCKET', 'renames the socket resolveTmuxSocketName() returns'],
['CLAUDE_CONFIG_DIR', 'relocates the Claude tree, so transcript fixtures under the temp HOME read as missing'],
['CODEMAN_CASES_PATH', 'bypasses the temporary HOME and points case routes at a deployment bind mount'],
];
const SETUP_SOURCE = readFileSync(fileURLToPath(new URL('./setup.ts', import.meta.url)), 'utf-8');
@@ -49,6 +50,10 @@ const SETUP_SOURCE = readFileSync(fileURLToPath(new URL('./setup.ts', import.met
const SETUP_STRIP_SECTION = SETUP_SOURCE.split(/^afterEach\(/m)[0];
describe('test environment isolation', () => {
it('removes every inherited CODEMAN_* environment variable', () => {
expect(Object.keys(process.env).filter((key) => key.startsWith('CODEMAN_'))).toEqual([]);
});
it.each(STRIPPED_ENV_VARS)('%s is unset while the suite runs', (name) => {
expect(process.env[name], `${name} leaked into the test environment`).toBeUndefined();
});