Compare commits

...
Author SHA1 Message Date
Codeman maintainer 96f89718a7 feat(tabs,header): group tabs by state and add header stats styles (#426)
Two directions from Discussion #426, each a per-device setting and the
new default.

Tab Grouping (tabGrouping: 'state' | 'none', default 'state', option C):
tabs are grouped needs you (red, plus failed sessions), waiting (yellow),
working and idle (also ended, exited panes and web tabs), most urgent on
top. The desktop header strip draws a row per group with its label and
count in a left gutter; the flat vertical rail and the sidebar draw a
section per group; tablets keep their scrolling row with inline dividers;
phones keep the chip row in group order without headings. Classification
is the home screens' own (_mobileOverviewState/_mobileOverviewExit), the
fold into four groups is pure in CodemanTabTriage (constants.js). It is
flex `order` plus aria-hidden heading/break elements reconciled in place
after both render paths, never a DOM reorder, so Alt+N, the keyboard walk
and drag keep reading tab order; a drop is refused across groups. Named
groups in the vertical rail take precedence.

Header Stats Style (headerStatsStyle: 'classic' | 'compact' | 'tiles',
default 'tiles', option G): tiles give WS, CPU, MEM and each plan window
a label-over-value tile with a bar underneath; compact is one WS/CPU/MEM
pill with sparklines plus a plan-ring pill; classic is the header as
before. Desktop only (classic below 768px and in solo windows). The
clustered styles move #connectionIndicator into #headerSystemStats and
#planUsageChip after it, and classic moves them back to comment anchors;
WS stays out of a hidden System Stats pill. The extra parts are always
rendered and hidden by default in CSS, so classic is unchanged.

Tests: test/tab-triage.test.ts, test/header-stats-style.test.ts; three
source pins in test/tab-rail-order.test.ts follow renamed lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:57:25 +02:00
Codeman maintainer ffaa5ee80c chore: version packages (1.34.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:39:41 +02:00
Codeman maintainer 6aecc3b858 chore: changeset for the 1.34.0 landing (#499, #514, #515, #517, #519, #520, #521, #522, #523, #524, #530, #531)
One consolidated minor changeset with the Thanks block first; the four contributor changesets (#520, #521, #522, #523) are folded into it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:53:56 +02:00
Codeman maintainer 470cf79776 fix(terminal): let a composition-only overlay follow the prompt, repaint it on removeChar, document the API (#499 review)
Merge-time fixes for the three findings of the third review round of #499.

- minor: a composition on an empty prompt did not follow the prompt after
  output or a resize. The post-write re-place in flushPendingWrites and the
  resize observer both ran rerender() only when hasPending was true, and
  hasPending deliberately excludes the composition, so the first word of a
  prompt (an overlay holding only a composition) stayed on the old row over
  whatever output moved there. Both sites now call rerender() unconditionally;
  it already returns early when there is nothing to draw, so nothing changes
  without a composition. New browser case drives the real
  batchTerminalWrite/flushPendingWrites path against real xterm 6 and the
  overlay built from source, moves the prompt from row 0 to row 3 and checks
  the overlay follows (it fails on the old guard, overlay left on row 0), with
  a parity case for pending text. The structure test pins the post-write site
  through vm and the resize site, which is a closure inside initTerminal(), by
  source.
- nit: removeChar() dropped the composition but did not repaint on its false
  path, leaving a composition-only overlay on screen showing text the addon no
  longer held. It now hides the overlay there when a composition was dropped.
  Package tests cover that path and the flushed path repainting without the
  tail.
- nit: the package README did not document setComposition() or the
  composition getter and described hasPending as "any content". Added both to
  the API tables plus a short IME composition section, reworded hasPending
  (pending or flushed text, excludes the composition), and made the quick
  start re-render unconditionally instead of teaching the hasPending guard.
  The hasPending JSDoc says the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer 06c4c7da16 fix(sessions): scope the launch model to claude and pin it with the advisor (#514, #515, #530 landing)
Maintainer merge-time fixes for the three PRs that landed together on the
session create / launch / persistence path.

#514 findings (bot verdict merge-with-fixes):
- minor, fixed: SessionState.model was published and persisted for every
  mode, so a codex/opencode cron session reported the app-wide Claude
  default it never ran on. toState() now emits it only where the new
  cliTakesSessionModel() holds (registry capability model.source ===
  'claude-settings-file', no CLI id branch). POST /api/sessions uses the
  same helper for its non-claude refusal, so refusal and publication cannot
  drift. Recovery then hands back undefined for other modes on its own.
- nit, fixed: the `model` schema admitted a leading dash (and '.', '[').
  The first character must now be a letter or digit; still a subset of the
  registry's model-claude pattern, so nothing accepted is refused at launch.
- nit, fixed (reject, the consistent choice): `model` with
  attachRemoteSession was silently dropped. Now a 400 INVALID_INPUT, as
  #514 does for non-claude CLIs and quick-start does for remote cases.
  advisorModel (#530) gets the same refusal there. effort and envOverrides
  keep their older silent ignore on that branch so no existing caller breaks.

#515 finding (bot verdict merge, one nit):
- nit, fixed: the types/session.ts @fileoverview described CodexConfig as
  (model, resumeSessionId); it now lists reasoningEffort, bypass,
  animations and renderMode too.

Audit of the merged combination (not reviewed before):
- The conflict resolutions in session.ts (toState), types/session.ts,
  reboot-restore-routes.ts, server.ts (restoreMuxSessions), CLAUDE.md and
  skills/codeman/reference/endpoints.md (+ plugin mirror) keep both sides
  correctly; nothing was lost or doubled.
- A claude session with both `model` and `advisorModel` launches with
  `--model <id>` and ONE merged `--settings` JSON (ultracode + advisorModel,
  or advisorModel beside `--effort <level>`), on the tmux template
  (including the resume || new variant and with the statusLine exporter)
  and on the direct-PTY fallback. Both values (and effort) survive
  restoreMuxSessions onto a dead pane, a reboot restore into a fresh pane,
  and restartCli/dead-pane respawn via _buildRespawnPaneOptions.
- quick-start and ralph-loop take no per-session `model` (matching #514's
  scope, POST /api/sessions only) and launch on the app-wide default, which
  toState now persists for claude, so recovery stays consistent.
- No defect found in the combination beyond the findings above. Noted, not
  changed: advisorModel is still published for any mode a caller sends it
  with (launch-inert there; the UI and skill send it for claude only).

Tests: test/session-model-recovery.test.ts pins the pair through both
recovery shapes for effort ultracode/high/none, the recovery constructors'
fields, the tmux-manager builder hop, and the codex/opencode/shell
non-publication; test/advisor-model.test.ts pins the launch lines and a
real direct-PTY Session's pty.spawn argv; the route test covers flag-shaped
models, attach refusals and the published fields. Docs: SessionState.model
docstring, the reboot-restore-registry header, the golden test comment and
the CLAUDE.md model/advisor bullets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer 7917273188 fix(tabs): collapsed-header alerts, quieter layout reads, tree key and touch fixes (#517, #519 review)
Maintainer merge-time fixes for the grouped vertical rail (#517) and its
tree semantics (#519), from the two PR reviews.

#517 minors
- A collapsed group hid rows that need the user with no signal on its
  header. The header now takes the most urgent alert among the session
  rows its collapse hides, in the tab alert language (tab-alert-action
  red ring, tab-alert-idle yellow ring, the existing ::before rules
  extended to the header). New pure hiddenGroupAlerts() over a per-section
  `hidden` list; _syncTabGroupHeaderAlerts() patches it on BOTH render
  paths, since alerts change without a rebuild. The kept selection draws
  its own ring and is not counted.
- Every layout read rebuilt the whole tab strip, and failed reads retried
  every 5 s forever. _applyTabLayout() now rebuilds only when the
  structure key changed. The key drops the layout version (bumped on
  every session create/close and order PUT) and instead carries group
  names and the rows each collapse hides, so a version bump that moves
  nothing costs nothing and a rename still rebuilds. The load coordinator
  backs off (5, 10, 20, 40 s, capped at 60 s) and stops after 4 retries;
  the next SSE init or tab:layoutChanged tries again, a success resets.
- A malformed stored collapse value disabled collapse on that device for
  good. A parse or shape error now reads as nothing collapsed and is
  rewritten to []; ok:false stays reserved for a store that throws.
- Ctrl+Shift+{ / } still reordered across groups, where the server
  re-ranks per group, sends no session:orderChanged and leaves this
  client's sessionOrder and Alt+N targets diverged. The move is now a
  no-op unless the neighbour is in the active session's own section
  (_canSwapActiveTabWith, reading the projection's new sectionByRef, which
  also covers rows a collapse hides). Within a group the swap still works
  and the server agrees with it; the flat rail and the strip are
  unchanged.

#517 nits
- Keyboard group toggle dropping focus: already fixed by #519's
  focus-by-identity; the Enter toggle test now pins focus on the header.
- Header <button> inside role=tablist: moot, #519 made the header a
  treeitem inside role=tree.
- Byte-identity test not comparing against master: skipped in the suite
  (a test cannot read another revision's files portably). Checked by
  hand instead: the flat strip and flat rail markup of this branch before
  and after this commit are identical in all 16 cases (both orientations,
  manual and activity sort, no layout and zero groups, full and
  incremental paths).
- Doubled blank line in docs/architecture-invariants.md: removed.

#519 minors
- A tap on a tree header or unselected row dismissed the touch keyboard:
  the roving tabindex parks those at -1, so the [tabindex] arm of
  MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR missed them. The selector now
  lists [role="treeitem"].
- The tree key handler acted on keys pressed on a focused control inside
  a row (Enter on the overflow button re-selected and reloaded the active
  session instead of reopening its menu). It now returns unless the key
  landed on the treeitem itself.

#519 nits
- aria-posinset/setsize went stale when the activity-sorted grouped rail
  re-sorted rows on the incremental path. The position pass is extracted
  (_applyTabTreePositions) and re-run, with aria-selected and the header
  alerts, at the end of the incremental branch while the rail is a tree.
- An expanded group with no open rows was announced as an expanded parent
  owning an empty group. A group with no open rows is now a tree leaf: no
  aria-expanded, no aria-owns, its rows container presentation; Left and
  Right do nothing on it, and its chevron keys off the section's
  collapsed class instead of aria-expanded.

Tests: tab-layout-browser (malformed storage, backoff with a bounded
drain, structure key, hidden alerts, leaf groups, sectionByRef),
tab-layout-rail (header alerts on both paths, render-on-change, backoff
without rebuilds, malformed storage, Ctrl+Shift section gate, in-row
control keys, leaf header keys, posinset after an incremental re-sort,
the dismiss selector matching tree items), and three new Chromium tests
in tab-activation.browser (Enter on a focused overflow button, the touch
keyboard staying up on tree taps, the collapsed header's red ring). Every
new test fails on the pre-fix sources. Docs: architecture-invariants
owner-tab-layouts and keyboard-dismissal sections, one clause in
CLAUDE.md's dismissal rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer b451b3851e fix(mcp): no file text in sync errors, follow relocated config dirs, docs and Settings polish (#521 review)
Maintainer merge-time fixes for the MCP server sync (opt-in mcpSyncEnabled, synced, default OFF).

M1, parse errors echoed config text (secrets included) into the HTTP response and Settings:
smol-toml's TomlError carries a code frame of the offending lines and V8's JSON "Unexpected
token" errors quote source. Both catch sites now go through describeMcpSyncError(): a parse
failure is reported by line/column only ("not valid TOML (line 3, column 21)", "not valid
JSON"), an errno failure by Node's own message (code, syscall, path), the module's own
messages via a McpConfigError class, anything else as "unexpected error". Tests put a secret
on the broken line (TOML, both JSON message shapes, and a write refused at the re-parse that
would have quoted a copied server's env) and assert it is absent from the result and from the
route's response body; they fail against the old code.

M2, CODEX_HOME / CLAUDE_CONFIG_DIR / XDG_CONFIG_HOME were ignored, so a sync could create a
file the CLI never reads and report success: new optional registry field
capabilities.mcpConfig.relocation { envVar, path } (registry data, no id branch; schema
reuses the env-name and no-traversal path rules). Declared for claude (CLAUDE_CONFIG_DIR,
checked in the 2.1.289 binary), codex (CODEX_HOME), opencode (XDG_CONFIG_HOME) and gemini
(GEMINI_CLI_HOME, gemini-cli paths.ts); antigravity follows $HOME only (agy 1.1.12 has no
relocation var). Resolved from the server process env at call time: absolute moves the file,
empty means unset, anything else reports the target with the new status "skipped" plus the
reason and writes nothing. Dedupe is now by resolved file. When a caller overrides `home`
without passing `env`, process.env is not consulted, and the route tests clear those vars so
a CI runner's XDG_CONFIG_HOME can never aim a write outside the temp HOME.

M3, feature undocumented: CLAUDE.md Key Patterns paragraph (opt-in, admin-only, additive
only, backups, re-parse validation, 0600 for copied secrets, names-only responses with
position-only parse errors, capabilities.mcpConfig and relocation), a Settings-Reference row
in the wiki, and docs/cli-registry.md + docs/api-reference.md updated for relocation, the
"skipped" status and the error policy.

Nits:
- N1 Preview/Sync before Save: the UI remembers the saved value on open and says "Save
  settings to turn MCP sync on first" instead of calling the routes; the 403 message also
  says to turn it on and save.
- N2 non-admins in multi-user mode: _applyMcpSyncAdminGate() hides the whole MCP group, called
  from applyMcpSyncVisibility() and the codeman:me event like the CLI-management gate.
- N3 scope chip says "synced".
- N4 "(1 servers)" pluralised; the unsupported list only names installed CLIs (route test
  pins it with a per-test installed set).
- N5 McpSyncResult / McpSyncTargetResult moved to src/types/mcp-sync.ts (barrel export); only
  the route imported them, so no churn.

Verified with an isolated instance (throwaway HOME, own instance and tmux socket) and
Playwright: chip, save-first message, preview rendering and the admin gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer 6d6e7da481 fix(notifications): main Save keeps webhook edits, glue test, docs and nits (#523 review)
Merge-time fixes for the webhook notification channel (ntfy, Slack, Discord, generic JSON).

Minor 1, App Settings Save silently dropped webhook edits: the modal's main Save now
persists the webhook group beside the settings PUT, the same way it already saves the
model config (saveModelConfigFromSettings), but only when the group differs from what
loadWebhook() put on screen (_webhookPending), so an untouched group never re-PUTs. A
refusal (bad URL, enabled with no URL) shows a warning toast, keeps the modal open and
scrolls to the group with the pasted URL still in the box, instead of a success toast.
Send test now saves pending edits first, so it never tests the old URL while the box
shows a new one. The row says so in one line.

Minor 2, no test for the server.ts glue: new test/webhook-push-glue.test.ts drives the
private sendPushNotifications on a real (never started) WebServer with an EMPTY push
store and webhook.json in the instance data dir, delivering through the real
egress-guarded fetch to a local receiver: a permission prompt arrives with the
host-prefixed ntfy Title and body while Web Push is never called, an immediate repeat is
deduped, "response complete" is skipped under scope attention and sent under all, and a
disabled config or a non-push event sends nothing. Verified it fails when the webhook
call is moved below the "no subscriptions" return.

Minor 3, docs: webhook.json added to CLAUDE.md State Files; a Webhooks section in
docs/wiki/Notifications-And-Approvals.md (setup, what is sent, the secret URL, public
ntfy topics, local targets allowed, dedupe, instance-wide reach in multi-user mode) plus
a table row, and a line in Settings-Reference; new section 10c in
docs/security-architecture.md for the second outbound channel through the web-tab
egress guard.

Nits:
- Orphaned JSDoc: the webhook schema moved below the push schemas, so
  PushSubscribeSchema has its comment back.
- Duplicated enums: WebhookUpdateSchema uses z.enum(WEBHOOK_KINDS/WEBHOOK_SCOPES), so
  the schema cannot accept a kind the store would coerce away.
- describeError classifies egress refusals with isEgressBlockedError (the
  CODEMAN_EGRESS_BLOCKED code anywhere in the cause chain) instead of a message regex;
  tests pin a deep cause chain and that matching words alone are not a refusal.
- Markup: the URL input uses set-input, the whitespace-only line is gone, and the switch
  row hints to pick a long random topic on public ntfy.sh.
- Remove a saved URL: a "Remove URL" button (shown only while a URL is saved, with a
  confirm) sends { url: "", enabled: false }.
- Types placement: WEBHOOK_KINDS/SCOPES and WebhookKind/Scope/Urgency/Config/Result/Status
  moved to src/types/push.ts (the IO-side WebhookMessage/Request/Fetch stay in the module).

Browser test extended: main Save persists a pending edit, a refused URL keeps the modal
open with the URL, Send test saves a newly pasted URL first, Remove URL clears it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer 52267f8617 fix(terminal): drive the shipped Shift+Enter handlers and the Key tester cap in their browser tests (#520, #522 review)
- Minor: the Key tester "14 lines" test never pressed a key into the
  tester (the previous test blurred it, so the presses landed on <body>
  and the cap was never exercised). It now refocuses the field, asserts
  the focus, clears the log, checks 2 presses accumulate to 6 lines, then
  4 presses of another key cap the log at exactly 14 with the oldest 4
  lines evicted in order, and the readonly field stays empty. Verified to
  fail with the cap changed to 20.
- Nit: the split-pane invariant implied Ctrl+Enter could use the CLI's
  declared newline chord. Reworded after checking the send-key route:
  Ctrl+Enter is always a real 0x0a, Shift+Enter is the declared
  capabilities.newline chord (0x0a unless the CLI declares another), sent
  on keydown only. The same imprecision in the auto-named sessions
  paragraph is corrected too.
- Nit: docs/wiki/Settings-Reference.md now lists the Key tester row in
  the Terminal & Input table.

- Nit: test/shift-enter-keypress.browser.test.ts exercised a hand-copied
  predicate named `shipped`. It now loads the real app from a real
  WebServer and presses real keys into the handlers terminal-ui.js
  (app.terminal, recording the real _sendInputAsync send path) and
  terminal-split.js (a real SplitTerminalPane) attach, recording the
  send-key POSTs through a fetch wrapper. It asserts no \r reaches either
  send path for Shift/Ctrl+Enter, exactly one send-key per press for the
  right session, and that Enter and Alt+Enter are untouched. The old
  keydown-only gate stays as a labelled reproduction of xterm's keypress
  behaviour on a bare Terminal. Verified to fail on both panes with the
  gate narrowed back to keydown.
- Nit: the keypress trap is now written down beside the other key-gate
  rules (Command palette and shortcut registry): xterm runs the custom
  handler for keydown, keypress and keyup and drops only Ctrl/Alt/Meta
  keypresses, so a gate on a chord that can carry Shift alone must
  swallow every event type. The smart-copy keydown-only rule points at it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:41 +02:00
Codeman maintainer 36af183f97 fix(split-pane): leave the owed marker to a trailing refresh, and say what a pull's request phase holds (#524 review)
- Stale second marker above a trailing refresh's replay: xterm parses
  write() on a later tick while clear() is synchronous, so a marker stamped
  in a load's finally, just before _endBufferLoad() starts the trailing
  refresh, landed in the freshly cleared buffer above that refresh's replay.
  _stampMarkerIfOwed() now returns early while a refresh is pending; that
  refresh re-owes the marker on a closed socket and writes the one copy
  below its own replay. Pinned by marker-count assertions on the two
  existing trailing-refresh tests plus a new async-parse fake (writes
  parsed on a later tick, clear() synchronous) for back-to-back refreshes
  and a pull with a queued refresh and a close mid-pull; all four fail
  without the guard. Also checked against a real @xterm/headless 6.0.0.
- Marker withheld for up to the 45 s request budget: kept the behaviour and
  made the comment and the docs truthful. The pull's request phase holds no
  live output, but it holds the single-flight flag, so a coalesced {t:'r'}
  refresh and a close's owed marker wait for the response. Writing the
  marker at once during that phase would need a separate "awaiting
  response" state and, with a refresh pending, reopens the same
  write-vs-clear() race as above; a Codeman restart resets the in-flight
  request along with the socket, so that pull fails at once and stamps.
- Stale comments: _onSocketClosed() now says the deferral covers any load,
  _writeDisconnectedMarker() points at _stampMarkerIfOwed(), and the pull's
  finally comment describes the hand-off to a trailing refresh.
- Invariants doc: dropped "the initial load" from the loads a close can land
  in (connect() awaits it before creating the socket), reworded the
  "nested refresh stamps its own" sentence to describe the guard, and noted
  what the request phase holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:52:27 +02:00
Codeman maintainer c029cea620 Merge pull request #499 from aakhter/pr/mobile-ime-preview
feat(terminal): preview IME composition text on iOS Safari

# Conflicts:
#	CLAUDE.md
2026-10-04 23:26:30 +02:00
Codeman maintainer b8038a592c Merge pull request #530 from Ark0N/feat/advisor-model
feat: Claude advisor tool support (per session, App Settings default, skill workers)

# Conflicts:
#	CLAUDE.md
#	plugins/codeman/skills/codeman/reference/endpoints.md
#	skills/codeman/reference/endpoints.md
#	src/session.ts
#	src/types/session.ts
#	src/web/routes/reboot-restore-routes.ts
#	src/web/server.ts
2026-10-04 23:25:01 +02:00
Codeman maintainer ccd6df893f Merge pull request #514 from irisitymichaelgrundberg/feat/claude-session-model
feat(sessions): accept a per-session Claude model on POST /api/sessions
2026-10-04 23:24:43 +02:00
Codeman maintainer e082d8e438 Merge pull request #515 from irisitymichaelgrundberg/feat/codex-reasoning-effort
feat(codex): start a codex session at a chosen reasoning effort
2026-10-04 23:24:39 +02:00
Codeman maintainer fa53a5751e Merge pull request #519 from aakhter/pr/grouped-rail-tree
feat(tabs): full-row activation and tree semantics for the grouped rail
2026-10-04 23:24:39 +02:00
Codeman maintainer 23d145b121 Merge pull request #517 from aakhter/pr/grouped-vertical-rail
feat(tabs): grouped vertical rail from owner tab layouts
2026-10-04 23:24:39 +02:00
Codeman maintainer 5724e0c8b4 Merge pull request #523 from opticon454/feat/webhook-notifications
feat(notifications): ntfy/Slack/Discord/generic webhook for the push events

# Conflicts:
#	config/test-suites.ts
#	docs/api-reference.md
#	src/web/public/settings-ui.js
#	src/web/routes/index.ts
#	src/web/server.ts
2026-10-04 23:24:39 +02:00
Codeman maintainer 9649b5019b Merge pull request #521 from opticon454/feat/mcp-sync
feat(mcp): sync MCP servers across enabled CLIs

# Conflicts:
#	src/config/cli-registry/schema.ts
#	src/config/cli-registry/types.ts
#	src/web/public/settings-ui.js
2026-10-04 23:24:26 +02:00
Codeman maintainer ec2a036543 Merge pull request #524 from timkjr/fix/split-pane-live-queue-timing
fix(split-pane): open Pane B's live queue after the response, keep the disconnected marker last

# Conflicts:
#	docs/architecture-invariants.md
2026-10-04 23:24:08 +02:00
Codeman maintainer c197e9370b Merge pull request #522 from opticon454/fix/newline-sequence-capability
feat(terminal): newline chord as registry data, plus a Key tester in Settings

# Conflicts:
#	config/test-suites.ts
2026-10-04 23:24:00 +02:00
Codeman maintainer d887002ca8 Merge pull request #520 from opticon454/fix/shift-enter-keypress
fix(terminal): Shift+Enter no longer submits after inserting a newline
2026-10-04 23:23:55 +02:00
Codeman maintainer 574f3db58b Merge pull request #531 from Ark0N/fix/statusline-exporter-tmp-race
fix(statusline): concurrent session creates no longer fall out of tmux
2026-10-04 23:23:55 +02:00
Codeman maintainer 17a976fa2e fix(statusline): unique temp name per exporter refresh, so concurrent creates stay in tmux
ensureStatusLineExporterScript() rewrites ~/.codeman/statusline-exporter.sh
via a temp file + rename whenever the script content changes (a fresh data
dir, or a release that changes it). The temp name was pid + Date.now(), so
claude sessions created in the same millisecond (spawn_workers, a multi-tab
Run) shared one temp path: the first rename consumed it and every other
writer failed with ENOENT on chmod or rename. createSession() treats that as
a mux failure and falls back to a direct PTY, so those sessions silently ran
outside tmux (no reattach after a server restart) while quick-start still
reported success.

Measured on a fresh isolated instance, 4 concurrent claude quick-starts:
master put 2 of 4 in tmux in both rounds; with this change 4 of 4, both
rounds. The temp suffix now comes from randomBytes, like the skill writer in
the same file and user-store.ts already do. The new test freezes Date.now()
and runs eight refreshes at once; it fails on master with the same ENOENT.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 21:36:44 +02:00
Codeman maintainer 7064b3c1d5 feat(skill): CODEMAN_WORKER_ADVISOR gives spawned claude workers an advisor
spawn_worker builds its quick-start body itself ({caseName, mode,
parentSessionId}), so an agent driving the skill had no way to give a worker
the advisor without hand-building the call and losing the readiness ladder,
hooks vetting and trust-dialog fallback. Setting CODEMAN_WORKER_ADVISOR
(fable / opus / sonnet) now adds `advisorModel` for every claude worker that
spawn_worker or spawn_workers starts; other modes ignore it.

A refused value fails the spawn with the server's INVALID_INPUT message. A
server without advisor support drops the field silently (the schema is not
strict), so spawn_worker reads it back and says so on stderr.

The preamble changed, so CODEMAN_PREAMBLE is bumped to 1.33.4 and stale
cached copies are rewritten instead of silently ignoring the variable.
SKILL.md's heredoc and the plugin mirror are synced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:05:49 +02:00
Codeman maintainer 01f403dc1b feat(claude): advisor tool support, per session and as an App Settings default
Claude Code's advisor tool (code.claude.com/docs/en/advisor) lets the session's
main model consult a second, stronger model at decision points: before
committing to an approach, on a recurring error, and before declaring a task
done. Codeman can now start claude sessions with one.

- `advisorModel` field on POST /api/sessions, /api/quick-start and
  /api/ralph-loop/start (fable, opus, sonnet or a full model id in those
  families; haiku cannot advise and is refused). Stored on the session and
  persisted, so respawn, boot restore and reboot restore keep it. Remote and
  docker quick-starts refuse it, as they refuse effort.
- App Settings, Models, "Advisor" segment (Default / Sonnet / Opus / Fable),
  synced as `claudeAdvisorModel`. Run, resume and the Ralph wizard send it.
  Default sends nothing, leaving the CLI's own /advisor choice in charge.
- Carried as the `advisorModel` key in the launch's single --settings JSON,
  merged with ultracode and the statusLine exporter, never the --advisor
  flag: `claude --advisor haiku` exits 1 at launch, which would leave a dead
  pane on every respawn, while the settings key degrades to no advisor. A
  launch without an advisor is byte-identical to before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:05:43 +02:00
DevvynandClaude Sonnet 5.5 2cf37529e9 fix(terminal): address review: Key tester isolates shortcuts, Codex stays on line feed
- app.js: the shortcut dispatcher returns early for events aimed at a data-raw-keys
  field, so Ctrl+W / Ctrl+L / Escape / Alt+1 / Ctrl+K pressed in the Key tester no
  longer kill the session, clear the terminal or close Settings
- stock.ts: drop Codex's esc-enter (a line feed works); no stock CLI declares a chord.
  The esc-enter path is tested through a clis.json override
- tests: unused port (3194), Ctrl+Enter asserts no keypress, shortcut-isolation test
  (verified to fail without the guard)
- docs/comments point at capabilities.newline; set-input class, trailing whitespace

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 11:19:42 +08:00
timkjrandClaude Sonnet 5.5 df398c5c68 fix(split-pane): open Pane B's live queue after the response, keep the disconnected marker last
Follow-ups from the #506 review.

The live-frame queue opened before the fetch, freezing Pane B for the
whole round trip. It now opens beside capturedAt; the request uses the
shared terminal fetch deadline and the body read a 10 s one.

A {t:'r'} refresh queued behind a pull ran its clear() after the
disconnected marker was written and wiped it, and a close during a
refresh load wrote the marker above the replay. The marker is now an
owed flag (_markerOwed) that each load settles in its own finally.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 10:00:11 -05:00
DevvynandClaude Sonnet 5.5 d68a173a23 docs+test(notifications): api-reference, changeset, switch click in browser test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 22:34:46 +08:00
DevvynandClaude Sonnet 5.5 0ff57ce304 feat(notifications): ntfy/Slack/Discord/generic webhook for the push events
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 22:06:29 +08:00
DevvynandClaude Sonnet 5.5 f39c66e4e8 feat(terminal): newline chord as registry data, plus a Key tester in Settings
capabilities.newline replaces choosing the Shift+Enter bytes in the send-key
route. Key tester shows the keydown/keypress/keyup a browser reports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 21:53:31 +08:00
DevvynandClaude Sonnet 5.5 4398dbfad0 feat(mcp): make sync opt-in and address review
Opt-in (mcpSyncEnabled, default OFF; routes 403 until on). Review fixes:
- codex TOML read/validated with smol-toml: CRLF, inline tables and
  command-less tables no longer yield a duplicate [mcp_servers.x]; the new
  text is re-parsed before writing
- null-prototype tables and own-key checks; unsafe names ignored at every level
- servers switched off in their own CLI (codex/opencode/antigravity) are not copied
- only CLIs that are installed or already have a config file take part
- files receiving env/headers are left 0600; symlinked configs are written through
- one apply at a time (409), unique tmp files cleaned on failure, failed status
- routes set real HTTP status codes; api-reference section; format type single-sourced

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 21:15:31 +08:00
DevvynandClaude Sonnet 5.5 c9a5fdab00 fix(terminal): swallow Shift+Enter keypress so it no longer submits
xterm runs the custom key handler for keypress too and drops Ctrl/Alt
keypresses but not Shift-only ones, so the stray \r submitted the prompt
after the newline. Swallow every event type for Shift/Ctrl+Enter and send
only on keydown, in the primary pane and Pane B. Adds a static guard and a
real xterm + Chromium browser test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 19:44:58 +08:00
DevvynandClaude Sonnet 5.5 7616de13de docs(mcp): document MCP sync in the CLI registry guide; unexport canExpress
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 19:06:37 +08:00
DevvynandClaude Sonnet 5.5 af032fc81a fix(mcp): block __proto__ server names, fix lint; add route and registry tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 18:27:07 +08:00
DevvynandClaude Sonnet 5.5 41a10b159e feat(mcp): add Antigravity, fix Gemini http/sse shape, report unsupported CLIs
Formats verified against real agy/gemini/codex mcp add output.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 18:27:07 +08:00
DevvynandClaude Sonnet 5.5 e6b258fc44 feat(mcp): sync MCP servers across enabled CLIs
Adds capabilities.mcpConfig to the CLI registry (Claude, Gemini, Codex,
OpenCode), an additive src/mcp-sync.ts, GET/POST /api/mcp-sync and a
Settings > Agents & CLIs control. Never edits or removes an existing
server; backs up each file it changes; reports conflicts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 18:27:06 +08:00
Aamer Akhter 98c6c1881d feat(tabs): full-row activation and tree semantics for the grouped rail
The grouped vertical rail is now an ARIA tree with a tree keyboard model,
and tab rows are pinned as full-row activation targets whose controls keep
their own actions and stable hit targets.

Tree semantics (grouped vertical rail only):
- #sessionTabs becomes role=tree while grouped and returns to its shipped
  role=tablist and label when grouping ends. The header strip, sidebar and
  flat rail keep role=tablist / role=tab exactly as before (the flat rail's
  markup is unchanged byte for byte).
- A named group's header is a level-1 treeitem with aria-expanded that
  aria-owns its rows' role=group (rows are level 2). Ungrouped rows and the
  row a collapsed group keeps showing are level-1 items; a collapsed header
  owns nothing, and the "Ungrouped" heading is a visual divider hidden from
  assistive tech. aria-level, aria-setsize and aria-posinset are set on every
  item, and aria-selected follows the selection without a rebuild.
- Exactly one treeitem carries tabindex=0 (roving). Controls inside rows
  leave the tab order, so Shift+F10 / ContextMenu open a row's actions
  (session action menu, web tab settings).
- Up/Down walk visible items, Home/End jump, Right expands a header or enters
  it, Left collapses a header or climbs from a row to its header, Enter/Space
  select a row or toggle a header. With the activity sort on, the walk follows
  painted order within each group; the flat list keeps its whole-list walk.
- Focus survives a full re-render by identity (a row a collapse just hid hands
  focus to its header), but a render never pulls focus into the rail.
- The group header is the treeitem itself (no nested button), still toggled by
  click through the same onclick and still the lineage proxy anchor.

Full-row activation:
- Clicking a row's status dot, mode chip, name or padding already selected it
  upstream; that is now pinned in real Chromium for the strip, the flat rail
  and the grouped rail, together with every control (gear, detach, close,
  overflow, web tab gear and close) running only its own action.
- The close control now shows a pointer like its siblings instead of the
  default arrow.
- Enter/Space on a focused web tab in the flat list opens it; it used to call
  selectSession(undefined).
- The action controls are pinned to stay under the pointer when a row is
  hovered (no reflow-on-hover moving the gear out from under a click).

New Chromium suite test/tab-activation.browser.test.ts is listed in
BROWSER_TEST_GLOBS (run with npm run test:browser).
2026-10-01 22:21:45 -04:00
Codeman maintainer 9240493c43 chore: version packages (1.33.3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:51:32 +02:00
Aamer Akhter 7cbce5bf6c feat(tabs): grouped vertical rail from owner tab layouts
The vertical tab rail now reads the owner's tab layout (GET /api/tab-layout)
and draws its groups as collapsible sections. This is the first frontend
consumer of the tab-layout backend and it is read-only: nothing in the
browser writes the layout yet.

- tab-layout-browser.js (new, pure, loaded before app.js): projects the
  layout onto the live sessions and open web tabs, renders the grouped
  markup, stores collapse per device, and sequences loads newest-wins with
  a bounded retry on failure.
- app.js: loads the layout on init and on tab:layoutChanged, renders the
  grouped rail from the same per-row markup the flat rail uses, falls
  through to a full render whenever the grouping structure changes, and
  withholds drag-reorder in the grouped rail.
- Grouping is opt-in by construction. With no layout, a failed read, a
  layout without groups, or a horizontal strip, the rail renders exactly
  as before (byte-identical markup).
- Grouping is a render layer only: sessionOrder, Alt+N, Ctrl+Tab and the
  palette keep reading the server-projected order, and row badges keep
  their Alt+N slot.
- A collapsed group still shows the active row; lineage arcs to a hidden
  session anchor to its group header.
- webview-tabs.js: renderWebviewTab() extracted so a single web tab can be
  placed into its group with unchanged markup.
2026-10-01 14:14:30 -04:00
Michael GrundbergandClaude Opus 5.5 3df113fc54 fix(sessions): keep a session's model through recovery, refuse it off claude
SessionState now carries the model a session launched with, and both
recovery constructors (mux recovery and reboot restore) pass it back, so a
recovered session relaunches on the same --model rather than the account
default. A top-level `model` sent with any other CLI is refused, since
those take their model in their own config object, and an empty string
means no per-session model, as it does for modelOverride.

CLAUDE.md now describes both routes for a Claude model. The tests pin
which of `model` and `modelOverride` reaches the launch and which the
case file, and that a model opening with a dash renders as --model's value.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:24:12 +02:00
Michael GrundbergandClaude Opus 5.5 0ae39cdd94 test(codex): pin reasoning effort through quick-start and the multi-user clamp
Both create schemas now refuse an unknown level, and a non-granted owner's
codexConfig keeps its reasoningEffort when the clamp forces bypass off.
docs/architecture-invariants.md lists the two --config values codex now
takes from codexConfig.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:16:18 +02:00
Michael GrundbergandClaude Opus 5.5 45db24bacf feat(codex): start a codex session at a chosen reasoning effort
codexConfig takes a `reasoningEffort`, one of the levels codex accepts,
and the session starts with `--config model_reasoning_effort=<level>`.
The registry declares one literal per level, gated on the enum, because
an argv token cannot splice a value into a literal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:59:46 +02:00
Michael GrundbergandClaude Opus 5.5 4123d229f4 feat(sessions): accept a per-session Claude model on POST /api/sessions
POST /api/sessions takes an optional `model`, and a Claude session
launches with `claude --model <id>`. It wins over the app-wide default
model and writes nothing to disk, unlike `modelOverride`, which stays as
it is and still writes the case's .claude/settings.local.json.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:59:45 +02:00
Codeman maintainer f776ad87b6 chore: changeset for the 1.33.3 landing (#503, #506, #507, #509)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:20:39 +02:00
Codeman maintainer 846c62fbf7 fix(web): bound a pending #session= link and retire it on Home or a web tab (#507 review)
- A #session=<id> link whose session never appears (closed, a typo, or
  another user's session in multi-user mode) is dropped after
  URL_SESSION_WAIT_MS (30 s) with a "Session not found" toast instead of
  waiting forever. One stored timer per link, cleared whenever the link is
  followed, replaced by a newer link, or retired.
- goHome() and opening a web tab now retire a waiting link, so a session
  that turns up later no longer takes the screen. App-made web tab opens
  (frame self-recovery, the fallback after the active web tab closes) pass
  auto: true and keep it, as selectSession() does.
- zh-CN translation for the new toast.
- selectSession's auto: true comment now lists the #session=<id> link.
- docs: the 30 s bound, a win.location.replace() tip that avoids piling up
  history entries, and the fragment declared a stable SemVer surface in
  versioning-policy.md.
- Tests: timeout drops and toasts, an early arrival is still selected, the
  wait does not restart, goHome and a web tab retire it, an auto web tab
  open keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:20:14 +02:00
Codeman maintainer 73c0bfccc4 fix(files): keep attachment markdown refs from resolving into the workspace, and render files without chat line breaks (#503 review)
- A markdown preview opened by attachment id under a bare file name
  (attachment cards, history drawer) no longer resolves relative refs
  against the workspace root: filePreviewText carries attachmentId, and
  the rebase pass turns those images into their alt text and unwraps
  those links. Absolute-path and workspace previews are unchanged.
- _renderMarkdown(text, { breaks = true } = {}): the File Viewer passes
  breaks: false, so a hard-wrapped paragraph renders as one paragraph;
  the Response Viewer keeps a <br> per newline.
- Absolute paths linkified inside a rendered document now carry the
  preview's data-session-id.
- CLAUDE.md, architecture-invariants and the Working-With-Files wiki page
  now say that only an in-workspace path clicked in the terminal keeps
  the tail viewer.
- Tests in test/file-preview-markdown.test.ts for all three fixes,
  including an end-to-end run of the shipping app.js + marked + DOMPurify.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:19:46 +02:00
Codeman maintainer 3af1ff6fae fix(split-pane): keep Pane B's disconnected marker last when the socket closes mid-pull (#506 review)
- terminal-split.js: move the socket's close into _onSocketClosed(), which
  defers the marker while a history pull holds live output (_liveQueue);
  _pullHistory() records closedBefore and its finally writes the marker
  after the queue flush when the socket closed during the pull, replayed
  or not, so it never lands above held frames or between replay chunks
- tests: drive the real close path for a close mid-fetch ending in a skip,
  a downgrade or a failed fetch, a close during the chunked replay, and a
  close with no pull running; pin the onclose wiring in the static guard;
  describe the mid-fetch case on its own
- CLAUDE.md: turn the plain-text split-pane pointer into a link
- architecture-invariants.md: describe the deferred marker

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:18:57 +02:00
Codeman maintainer 988f111cd0 fix(ui): keep a focus the row menu moved when closing the Session Manager (#509 review)
- _restoreOverlayFocus(key, modal) now leaves focus alone when something
  outside the overlay already holds it (not <body>, not inside the modal).
  The Session Manager's "Switch to session" and "Open folder" call
  selectSession() before closeSessionManager(), and the restore was pulling
  focus back from the terminal to the header button. Both close methods pass
  their modal; a regression test drives that order.
- Test harness: focusHarness() routes getElementById through a local binding
  instead of leaking globalThis.__els, and its modal stubs report their own
  search box as contained, as the real DOM does.
- CLAUDE.md and docs/architecture-invariants.md: record that the global
  Escape handler calls every close method on every Escape (capture phase),
  so a close method with side effects must return early when not open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:18:56 +02:00
Codeman maintainer 5f5de5827e Merge pull request #503 from JDProfresh/feat/file-viewer-markdown
feat(files): render markdown in the File Viewer, with Lines/Wrap toggles
2026-10-01 11:09:26 +02:00
Codeman maintainer 0aba9f6ec8 Merge pull request #507 from irisitymichaelgrundberg/feat/select-session-from-url
feat(web): select a dashboard session from a #session=<id> link
2026-10-01 11:09:26 +02:00
Codeman maintainer 61dbd97ba4 Merge pull request #506 from timkjr/fix/split-pane-scroll-history
fix(split-pane): let a Shell Pane B's scroll-up reach tmux history
2026-10-01 11:09:26 +02:00
Codeman maintainer b5d8122ea7 Merge pull request #509 from dignfei/fix/overlay-focus-restore
fix(ui): stop Escape from stranding the keyboard after closing an overlay
2026-10-01 11:09:25 +02:00
d fei 57f7a77573 fix(ui): restore focus only when the overlay was actually open
Review feedback. The global Escape handler in app.js calls both
`closeSessionManager()` and `closeCommandPalette()` on every Escape, whether or
not either overlay is open, in the capture phase. Nothing was saved in that
case, so `_restoreOverlayFocus()` fell through to `terminal.focus()` and moved
focus before the focused element's own Escape handler ran:

- split view: with focus in Pane B, keys typed after Escape went to Pane A
- any text field (File Viewer editor, search and history filters, case picker):
  keys typed after Escape went into the terminal
- inline tab rename: the capture-phase focus fired the input's blur (which
  commits) before its own Escape handler (which cancels), so Escape committed
  the rename instead of cancelling it

Both close methods now bail out on `classList.contains('active')`.

Separately, gating the terminal fallback on `activeSessionId` alone only covered
the welcome screen. On a touch device with the keyboard down, focus sits on
`<body>`, so closing the Session Manager focused the terminal and brought the
keyboard up — `selectSession()` deliberately skips that focus, and this
overrode it. It now goes through `_shouldFocusTerminalForTabSwitch()`.

Tests: the Session Manager case's modal stub now uses the harness's
`makeClassList()` (without `contains` the new guard reads it as "not open" and
skips the restore the case is about), plus two new cases — closing either
overlay without opening it first with an active session asserts the terminal was
not focused, which is the path the global Escape chain takes and none of the
five existing cases covered, and a touch device with the keyboard down asserts
the same. Each was checked against the unguarded code: removing either guard
turns exactly its own case red.
2026-09-30 08:35:06 -07:00
d fei af4cc45cfd fix(ui): stop Escape from stranding the keyboard after closing an overlay
Both the Command Palette and the Session Manager call `search.focus()` on
open, and both closed by removing the `active` class and nothing else. Hiding
a focused input does not hand focus back to anyone — the browser drops it on
`<body>` — so after Escape closed the overlay every keystroke went nowhere and
the user had to click the terminal before they could type again.

Measured in headless chromium against a real shell session, one overlay at a
time:

  overlay            activeElement after Esc   can type afterwards
  App Settings       XTERM                     yes
  Session Options    XTERM                     yes
  Token Stats        XTERM                     yes
  Monitor Panel      XTERM                     yes
  Session Manager    BODY                      no    <- fixed here
  Command Palette    BODY                      no    <- fixed here

The four that worked did so because they use `FocusTrap`, whose `deactivate()`
restores focus to whatever held it before. These two never got one. Every close
path has the same hole — Escape, the close method, picking an item — so the
restore lives in the close functions rather than in the global Escape chain.

Deliberately only the save/restore half of `FocusTrap`, not the whole thing:
`FocusTrap.activate()` moves focus to the first focusable element, which in
neither overlay is the search box, so adopting it wholesale would trade "type a
filter the moment it opens" for "focus survives the close" — and the former is
the reason Cmd+K exists. The terminal fallback is gated on there being an
active session: an overlay opened from the welcome screen has no terminal to
return to, and focusing one on a phone summons the on-screen keyboard over a
screen with no input on it.

The five new cases were checked against the unfixed code first: four of them
fail without this change.
2026-09-29 18:02:05 -07:00
Michael GrundbergandClaude Opus 5.5 01eb8ef08a feat(web): select a dashboard session from a #session=<id> link
A page that keeps one Codeman window open, such as a task board, could only
show a session by sending that window to /session/<id>, which loads the whole
app again for every click. The dashboard now reads a #session=<id> fragment
when it loads and on hashchange, selects that session, and removes the
fragment with history.replaceState so the next identical link is still a
change. Re-pointing a window that already shows the dashboard changes only the
fragment, so the page stays loaded and the switch is a tab change.

A link can name a session the dashboard does not list yet, because the page
that created it may link before session:created arrives. The id waits until
that event names it, and picking another tab yourself retires it.

Following a link is an app selection (`auto: true`). The page that set the
fragment may be a script, so it must not spend the session's idle alert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 18:03:55 +02:00
timkjrandClaude Opus 5.5 140ca35e2d fix(split-pane): keep the disconnected marker visible, skip detached sessions
Address Ark0N's review on #506:

- The history pull's own `\x1bc` reset erased the "Pane B disconnected"
  marker onclose wrote, painting a fresh, current-looking history while
  onData kept silently dropping every keystroke on the dead socket — a
  Codeman restart drops the socket while the tmux session (and so the HTTP
  pull) survives, making this easy to hit. onclose now tracks the closure
  via `_wsClosed` in addition to writing the marker (extracted into
  `_writeDisconnectedMarker()`), and a replay re-stamps it in the pull's
  `finally` block, after the live-frame flush, whichever order the close
  and the pull land in.
- `_maybeLoadMoreHistory()` now stands aside for a detached session,
  mirroring `_sendResize()`'s existing check and app.js's
  `_maybeRefetchFullHistory()` — its own window already owns its PTY size
  and scrollback.
- Wording: a non-shell CLI's history is out of scope for this pull, not
  absent (codex and Claude's inline renderer do grow tmux history); the
  alternate-screen skip only matters for a direct-PTY shell, since tmux
  never surfaces the alt buffer to the browser xterm. CLAUDE.md points at
  the invariants heading directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:59:22 -05:00
timkjrandClaude Opus 5.5 17232b01f6 fix(split-pane): let a Shell Pane B's scroll-up reach tmux history
tmux repaints a burst of output instead of scrolling it, so a shell
pane's xterm keeps about one screen of scrollback while tmux holds every
line. The primary pane goes back for it when the wheel reaches the top;
Pane B is a separate xterm that loaded history once at connect and never
again, so after a `cat` its earlier output was unreachable.

Pane B now does the same for a shell session: wheel-up at the top of the
normal screen pulls ?full=1&tail=TERMINAL_TAIL_SIZE and holds the
reader's place across the replay. The wheel listener is capture-phase
because xterm stopPropagation()s the events it consumes.

It follows the primary pane's rules from #494 and its 1.33.2 merge-time
fixes: a window holding no more rows than the pane (which covers a
downgrade), or a pane already at its `scrollback + rows` cap, is skipped
without a rewrite. That skip backs off to 60 s when the window was
truncated or the pane is full, since each ask costs the server a
whole-history capture-pane; an untruncated window keeps the 4 s cooldown.
There is no truncation banner in Pane B, so the 'tail' relabel does not
apply.

Live frames, a {t:'c'} clear included, are held with their arrival time
while the replay runs and applied in order only if they arrived after the
capture. The fetch has a 10 s deadline since it holds live output while
it runs. The tail of _loadBuffer() becomes _endBufferLoad() so the pull
shares its single-flight bookkeeping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:24:08 -05:00
JD 612c69d57a fix(files): decode markdown refs, scope links to the preview session, drop name= from the sanitizer
Review follow-up on #503. marked percent-encodes link and image destinations, and the rebase pass encoded them a second time, so a space or a CJK character in a file name made file-raw look for a file literally named my%20image.png; refs are now decoded once (a malformed escape is kept as written) and stripped of ?query along with #fragment. Root-relative refs resolve from the workspace root as on GitHub instead of falling through as Codeman URLs. Rebased links carry the preview's own session id and the response-viewer delegate prefers it, so a document opened from another session's attachment card opens its links in that workspace rather than the active tab's.

The sanitizer no longer allows name=: marked never emits it, and <img name="app"> made document.app that image, which every inline onclick="app.…()" handler resolves before the global, so one rendered README broke every viewer button until a reload. Adds the zh-CN strings for the three toolbar titles.
2026-09-28 15:22:37 -04:00
Codeman maintainer 848ab48b0a chore: version packages (1.33.2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:35:45 +02:00
Codeman maintainer 0b106b03eb chore: add the cron paste-mode fix to the landing changeset
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:25:00 +02:00
Codeman maintainer 54c591c84d Merge origin/master (cron paste-mode Enter fix) into the landing branch 2026-09-28 17:24:52 +02:00
Codeman maintainer 2eece4f8f9 fix(cron): send a paste-mode prompt's Enter as its own write
A cron job in "Paste (direct)" input mode wrote `<text>\r` into the pane
in one piece. Claude Code (measured on 2.1.283) takes a burst of about a
hundred characters as a paste, so the `\r` landed as a newline and the
prompt sat unsent on the composer while the run reported `prompt_sent`.

Delivery now lives in `deliverCronPrompt()`. Paste mode writes the text
raw, waits CRON_PASTE_ENTER_DELAY_MS (300 ms), sends `\r` as a separate
write down the same PTY (so it cannot overtake the text), and arms the
session's composer check through the new public
`Session.verifySubmitted()`, which re-presses Enter while the prompt is
still visibly unsent. A session with nothing to write to now fails the
run instead of reporting the prompt as sent. Typed mode is unchanged.

Verified on an isolated instance: a paste-mode job with a 104-character
prompt submitted on the first Enter and Claude answered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:10:12 +02:00
Codeman maintainer 4d165d3fb1 chore: add the input-delivery fix to the landing changeset
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:46:04 +02:00
Codeman maintainer d5ffc22f4a Merge the input-delivery fix from master
fix(input): deliver API prompts through tmux so their Enter is not lost
2026-09-28 16:45:27 +02:00
Codeman maintainer c2dfc775a3 fix(input): deliver API prompts through tmux so their Enter is not lost
A prompt posted to /api/sessions/:id/input without `useMux` was written
into the pane in one piece. Claude Code (measured on 2.1.283) takes a
`<text>\r` burst of about a hundred characters or more as a paste, so the
trailing `\r` landed as a newline in the composer and the prompt sat there
unsent while the route answered 200. A later raw `\r` did not recover it;
a tmux `send-keys Enter` did. Short prompts submitted, which is why it
looked random. The same stranding was seen with Codex and OpenCode.

A plain prompt (printable text plus exactly one trailing `\r`, detected by
`isPlainPromptInput()`) now goes through `writeViaMux` even without
`useMux`: the text is typed, Enter is pressed as its own key, and the
SubmitVerifier re-presses it while the prompt is still on the composer.
The write is awaited, since the browser's POST fallback sends frames one
at a time and a following keystroke must not overtake the Enter. Raw
frames (escape sequences, bracketed paste, a line feed, a bare `\r`) and
an explicit `useMux: false` keep the direct write.

Verified on an isolated instance: the 239- and 104-character prompts that
stranded (at +1 s, at +50 s on ultracode, and on a warm session) all
submitted on the first Enter with no `useMux`. The phone's local-echo
flow (a burst, then its `\r` as a separate write) was measured unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:44:59 +02:00
Codeman maintainer e439cf0ef3 chore: changeset for the 2026-09-28 landing
Folds the #490 and #492 contributor changesets (the latter said minor) into one patch changeset with the Thanks section, one paragraph per change and the fixes applied while landing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:30:56 +02:00
Codeman maintainer 1f4c390e12 fix(terminal): merge-time fixes for #498
- _logScrollRouting() reports cliMouseTracking, the gate's new input, in both
  the de-dup signature and the console line (xterm's own mouseTracking stays
  'none' for Claude, so it gave no reason for a no).
- Restore two guard tests the new gate made vacuous: the local-scrollback
  opt-out footgun test and the codex/gemini "no version rescues it" fixtures
  now set cliMouseTracking: true, so removing the opt-out or re-adding codex to
  the gate fails again.
- Update the comments and architecture-invariants lines that still described
  the version-only rule (wheel handler header, gate doc, the false paths of
  _maybePageCliTranscript, "holds a tracking mode on continuously").
- Name both fullscreen switches (CLAUDE_CODE_NO_FLICKER=1 and "tui":
  "fullscreen" in ~/.claude/settings.json) in the code comment, the invariants
  and the two wiki pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:30:40 +02:00
Codeman maintainer 714050fe8a fix(terminal): merge-time fixes for #494
- Skip and latch a bounded Shell window once the browser is at xterm's
  scrollback cap (scrollback + rows): a 1 MiB window of short lines can carry
  more rows than the browser can ever hold, so it replayed and re-captured on
  every scroll-to-top with no 60 s back-off.
- Label a replayed bounded window 'tail' even when the capture was byte-capped,
  so the banner keeps offering Load full history instead of calling the rest
  unrecoverable.
- Pin GET /terminal?full=1&tail=<n> in the route tests: full-history source,
  truncationReason 'tail', and the closing relative cursor move survive the cut.
- Log the bounded skip via _logScrollRouting('repull-skipped-bounded').

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:30:40 +02:00
Codeman maintainer dfd3df8289 fix(build): merge-time fixes for #500
- pre-push hook: skip with a notice when npm is not on PATH (GUI git
  clients and IDEs often run hooks with a minimal PATH), instead of
  blocking every push on "npm: not found"; real-push test with a
  stripped PATH
- test/git-hooks.test.ts: pin GIT_CONFIG_NOSYSTEM=1 and
  GIT_CONFIG_GLOBAL=/dev/null around the resolveGitHooksDir tests, so
  an exported global or a system core.hooksPath no longer fails them
- watch tsconfig.json, .prettierignore and .editorconfig too:
  typecheck and format:check read them
- check:browser-excludes: fail loudly when the vitest list output and
  the walked test/**/*.test.ts tree share no path (format drift would
  otherwise pass vacuously)
- Reword the PRE_PUSH_MARKER comment: bumping its version would make every
  installed v1 hook read as foreign and never refresh again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:56 +02:00
Codeman maintainer a0fbd1d28d docs(registry): note the cliMouseTracking half of claude's wheel rule (#498)
- claude's declared-for-later wheelForward says the live rule in
  _shouldForwardWheelToApp is the version AND the server-published
  cliMouseTracking flag, so whoever wires the field up needs both

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:29 +02:00
Codeman maintainer 272b56d47b fix(session): merge-time fixes for #491
- claude watchingLine: the lookahead keys on "Artifact" alone, so a
  footer truncated mid-chip ("1 Artifact…", "1 Artifact comm…") is still
  refused instead of reporting the shell beside it; comment follows
- test: both truncations return no watching label
- invariants: a chip that waits on a human never counts as watching, and
  the ^ anchor is what stops the retry past the chip

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:29 +02:00
Codeman maintainer 1645ef5f5c fix(docker): merge-time fixes for #492
- test: the complete-identity case now checks the combined
  agentImageBuildArgPairs() argv on both producers, so the manual
  build-agent-image.mjs path cannot drop the identity unnoticed
- both producers: GIT_IDENTITY_BUILD_ARGS carries the mirror/parity
  warning its gh/az neighbour has
- the partial-identity error names CODEMAN_AGENT_IMAGE_GIT_USER_NAME and
  CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL; test regex follows
- wiki Docker-Cases: mention the identity variables next to the gh/az
  switches

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:29 +02:00
Codeman maintainer 627b76739c fix(docker): merge-time fixes for #490
- test: every ENV PATH= line in server.Dockerfile must start $PATH:, and
  the ~/.local/bin append is pinned alongside /opt/codeman-cli/bin
- invariants + CLAUDE.md: the append-only PATH rule names ~/.local/bin too
- docker-compose.md: Settings-installed CLIs live in ~/.local on the
  app-data mount; reinstall once after upgrading; hand-run npm installs
  need --prefix ~/.local
- installEnv() JSDoc describes the in-container npm prefix redirect

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:29 +02:00
Codeman maintainer 83e39c40a1 Merge pull request #504 from Ark0N/fix/phone-tab-strip
fix(mobile): make the phone header tab strip read as live tabs
2026-09-28 16:21:10 +02:00
Codeman maintainer fec0409315 Merge pull request #500 from aakhter/pr/prepush-browser-excludes
build: add a browser-test exclusion check and a pre-push static-check hook
2026-09-28 16:21:09 +02:00
Codeman maintainer b4954c14cd Merge pull request #494 from timkjr/fix/shell-scroll-history
fix(terminal): let a Shell pane's scroll-up reach tmux history

# Conflicts:
#	docs/wiki/The-Dashboard.md
2026-09-28 16:21:08 +02:00
Codeman maintainer c9f47b095a Merge pull request #498 from JDProfresh/fix/claude-inline-scroll
fix(terminal): only forward scroll to Claude while it tracks the mouse
2026-09-28 16:20:57 +02:00
Codeman maintainer 47ac16d6ab Merge pull request #492 from opticon454/feature/static-git-identity
feat(docker): configure static git identity
2026-09-28 16:20:56 +02:00
Codeman maintainer 6d147c1bf1 Merge pull request #490 from opticon454/feature/docker-uv-uvx
fix(docker): keep CLIs installed from Settings across container updates
2026-09-28 16:20:54 +02:00
Codeman maintainer 92921b9107 Merge pull request #491 from irisitymichaelgrundberg/fix/artifact-comment-monitor-needs-you
fix(session): alert for an agent waiting on artifact comments

# Conflicts:
#	src/config/cli-registry/stock.ts
2026-09-28 16:20:52 +02:00
Codeman maintainer 614c7e6cd5 Merge pull request #501 from aakhter/pr/webview-sse-owner
fix(webview): route webview:changed only to its owner in multi-user mode
2026-09-28 16:20:35 +02:00
Codeman maintainer 7659ca8b44 fix(session): keep a tab working while Claude waits for its own workers
When Claude hands work to an ultracode workflow or background agents, it
ends its own turn and closes it with `✻ Waiting for 1 dynamic workflow to
finish` instead of `✻ Brewed for 1m 18s`, then resumes by itself when the
workers report back. The pane sits quiet with the composer up, so the idle
probe called the session idle for the whole wait. At phone width the
workflow's progress row also drops its ticking timer, so nothing on screen
changes for minutes.

A new optional registry field, `capabilities.workDetect.awaitingLine`,
names that closing row, and `_probePaneWorking()` counts it as work.
Claude renders the row once from a snapshot and never redraws it, so the
same words stay on screen after the workers finish. `isAwaitingWorkers()`
therefore tests only the newest column-0 row directly above the composer,
never the whole pane and never the PTY stream; a follow-up turn always
puts rows of its own there. The column-0 anchor also keeps an agent from
holding its own tab busy by printing the sentence.

Verified against the live Mac mini pane that reported the bug (2.1.283),
and end to end on an isolated instance: an ultracode session running a
90 s workflow at 46 columns stayed busy through the wait and the
follow-up turn, then went idle 6 s after that turn closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:18:27 +02:00
Codeman maintainer 61037082d1 fix(mobile): make the phone header tab strip read as live tabs
On a phone every inactive tab rendered transparent: grey 11px text
floating in unmarked gaps, a boxed Alt+N digit in each tab (a phone has
no Alt key), names capped at 50px so a shared `w1-` prefix was most of
what showed, and the tab that did not fit was chopped mid-word against
the connection dot. The strip looked like a row of disabled labels.

Phone block of mobile.css only:
- Every header tab is a chip, filled and bordered from the skin's
  --control-* tokens, name in --text at weight 500. Written
  `:where(.header) .session-tab` so it stays at (0,1,0): the per-colour
  left border still wins, and sidebar layout (where the list leaves the
  header) is untouched.
- The Alt+N digit is hidden in the header; inactive tabs drop their
  empty .tab-actions container, which padded the chip's right side.
- Name cap 50px -> 80px, status dot 4px -> 6px, strip gap 2px -> 6px.
- Scroll-driven edge fade: a mask on the strip whose widths follow its
  own inline scroll timeline (registered @property lengths), so the
  clipped tab dissolves into the edge. No JS; a strip that does not
  overflow gets no mask, and browsers without scroll timelines keep the
  old hard edge.

The tap-zone arithmetic comment is updated for the numberless phone
tabs and the bigger dot (the required reserve drops from 38px to 36px;
the 44px min-width stays). test/mobile-tab-strip-chips.test.ts pins the
(0,1,0) selector, the top-level @property registration and the
timeline-after-shorthand order, each of which fails silently otherwise.
test/mobile/tabs.test.ts follows the new name cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:23:22 +02:00
JD 5e27043bf7 feat(files): render markdown in the File Viewer, with Lines/Wrap toggles
Clicking a .md in the Files panel showed wrapped source with an Edit
pencil and no way to see it rendered, although marked + DOMPurify were
already on the page for the Response Viewer. The viewer now renders
.md/.markdown through that same pipeline (one parser, one click
delegate) with an MD pill back to source, and the plain-text view gains
Lines (CSS-counter gutter) and Wrap toggles. All three persist per device
in their own localStorage keys.

- Relative images are rebased onto the workspace-confined file-raw route
  under the document's directory, built inside a <template> so no fetch
  fires before the rewrite; a failed load degrades to alt text. Relative
  links become a.rv-path so the existing delegate opens them in the
  viewer; fragment and http(s) links are untouched.
- The rendered container carries data-i18n-skip so the translator does
  not rewrite the document's prose.
- Markdown fetches the route's 10000-line ceiling; other text keeps 500.
- avif renders inline (file-content image set, file-raw MIME map), and
  avif/ico printed paths open the viewer instead of tailing bytes. .md
  deliberately stays with the tail viewer for printed paths.
2026-09-28 01:49:37 -04:00
Aamer Akhter ee1a155e2c fix(terminal): draw IME preview after local-echo text
With local echo on, committed text sits in the LocalEchoOverlay and does not
reach the PTY before Enter, so the PTY cursor that places the preview span
stays at the prompt start. The span's z-index 6 only counts inside
.xterm-helpers (its own z-index 5 stacking context), and the overlay is a
z-index 7 layer whose first line is opaque from the prompt column, so every
composition after the first one in a prompt was drawn under the overlay.

- xterm-zerolag-input: add setComposition(text) and a composition getter.
  The overlay draws the composition as an underlined, aria-hidden tail after
  its pending text, through the same wrapping and grow-upward layout. It is
  never part of pendingText, hasPending or anything sent; clear() and
  removeChar() drop it, and rerender()/refreshFont() keep it.
- terminal-ui.js: while local echo shows typed text (on, and not handed back
  to PTY echo by a nav key), render and clear the preview through
  setComposition. The helper span stays for local echo off, and as the
  fallback when the overlay cannot place the text (no prompt found).
- Browser test against real xterm 6, the overlay bundled from its source
  and styles.css: a second composition after pending text is the topmost
  element after that text, and the commit lands in the overlay once. Unit
  tests for setComposition in the package and for the routing in the
  structure test.
- CLAUDE.md and architecture-invariants: state the preview's effective layer.
2026-09-27 07:56:22 -04:00
Aamer Akhter 2d96472dbe fix(terminal): address review of the iOS IME preview
- Observe keydown in the capture phase on terminal.element, an ancestor of
  the helper textarea, so the controller sees it before xterm's own capture
  listener finalizes the composition and emits the commit through onData.
  Finalize on exactly the keys CompositionHelper.keydown does (every keyCode
  except 20/229/16/17/18), ignoring isComposing and key as xterm does.
- Bound awaitingCommit with the same 2 s fallback as the committed phase, so
  a composition whose commit never reaches onData cannot turn the next
  unrelated keystroke or paste into an IME commit.
- pagehide resets the controller instead of destroying it, so a back-forward
  cache restore keeps the preview working.
- Give the preview an opaque background from the terminal theme.
- Route an IME commit through the ordinary printable/paste local echo branch
  and complete the commit afterwards; drop the send-on-throw fallback.
- Pin the event order with an xterm stand-in registered in the capture phase
  ahead of the controller, and against real xterm in a browser test.
- CLAUDE.md: note the IME commit routing and the z-index 6 preview layer.
2026-09-26 22:47:48 -04:00
Aamer Akhter e71971cab4 fix(webview): route webview:changed only to its owner in multi-user mode
webview:changed carried only {action, id} and the SSE routing hint had no
webview: branch, so every connected client received it: in multi-user mode
any user saw the ids of other users' web-tab creates, edits and deletes.
The event now carries the web tab's owner (from the stored record) and is
routed to that owner plus admins. Single-user delivery is unchanged.
2026-09-26 22:45:35 -04:00
Aamer Akhter e60b5a8a2c build: address review on the pre-push hook and hooks-dir resolution
resolveGitHooksDir now returns a directory only when it is the repo's own
<git-common-dir>/hooks (compared on canonical paths), so a core.hooksPath
elsewhere, global or repo-local, is never written to by postinstall, while a
core.hooksPath pointing back at the repo's own .git/hooks still resolves.

The pre-push hook skips with a one-line notice when a pushed ref is not the
checked-out HEAD (tags peeled) or when git status shows uncommitted or
untracked changes under a path the checks read (src, config, scripts, test,
package.json, package-lock.json, install.sh), since the checks read the
working tree rather than the pushed commit.

Also: honest timing (~10-40s instead of ~15s), CLAUDE.md Session Safety note
on CODEMAN_SKIP_PREPUSH for another session's WIP, 14 (not 9) Playwright
tests, and a note that the browser-excludes check only sees direct imports.
2026-09-26 22:44:14 -04:00
Aamer Akhter 1d85909a06 build: add a browser-test exclusion check and a pre-push static-check hook
npm run check:browser-excludes finds tests that import a browser driver and
asks `vitest list` whether the CI config still collects them; wired into CI.
npm install now also installs a marker-owned pre-push hook that runs the
static CI checks (~15s). Skip with CODEMAN_SKIP_PREPUSH=1; hand-written
hooks are left alone.
2026-09-26 18:45:17 -04:00
Aamer Akhter e0542bb172 feat(terminal): preview IME composition text on iOS Safari
WebKit on iOS does not show text being composed by an IME inside the
terminal, so users type blind until it commits. Add mobile-ime-preview.js,
a visual-only controller that renders the composition in the xterm helper
layer and holds a committed chunk until local echo, parsed terminal output
or a 2s fallback shows it. Wire it into terminal-ui.js, the script order,
the build minify/hash lists and styles, with unit and wiring tests.
2026-09-26 17:06:11 -04:00
JD 1da2fa2529 fix(terminal): only forward scroll to Claude while it tracks the mouse
Claude 2.1.280 renders inline by default: no alt screen, no mouse tracking, transcript in real scrollback. The version-only gate still sent every wheel tick and touch swipe as SGR reports, which Claude ignores, so scrolling a Claude session was dead while codex (routed locally) worked. Gate forwarding on the server-recorded cliMouseTracking flag, which fullscreen mode (CLAUDE_CODE_NO_FLICKER=1) sets.
2026-09-26 15:49:58 -04:00
Codeman maintainer 45ea2e1d32 docs(readme): ask readers to star the project
Adds a centered star call-to-action under the badge row in both the
English and Simplified Chinese READMEs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 04:39:13 +02:00
timkjrandClaude Sonnet 5 f6aa50239f fix(terminal): skip a bounded Shell window before the downgrade guard
A window cut at the tail size can be smaller than the browser's buffer
while tmux still holds more. The downgrade guard reads that as "tmux has
nothing more to give", which is true of an unbounded capture only, so a
bounded window reaching it marked the session exhausted and removed Load
full history from the banner.

The bounded skip now runs first, so such a window never reaches the
exhausted path, and it no longer writes banner state: relabelling it from
the bounded payload would call a terminal holding all of a Load full
history pull "the most recent 1 MiB".

A skipped window that came back truncated cannot reach anything older
than the browser shows, and every ask costs the server a synchronous
capture-pane of the whole history (tail is applied after the capture), so
it puts the session on the 60 s cooldown. An untruncated one keeps 4 s.

_replayWouldShrinkBuffer takes optional pre-estimated rows so a megabyte
capture is not scanned twice. CLAUDE.md's Full-scrollback replay entry no
longer says Shell never pulls on ordinary scroll.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 20:17:22 -05:00
timkjrandClaude Opus 5.5 9676e90133 fix(terminal): let a Shell pane's scroll-up reach tmux history
A burst of output leaves a Shell pane with about one screen of browser
scrollback, because tmux repaints the burst instead of scrolling it,
while tmux itself keeps every line. Shell declined the scroll-to-top
re-pull other modes use, and the Load full history button renders only
once a replay was truncated, so a Shell tab under 1 MiB could not
scroll back at all.

The scroll gesture now pulls ?full=1&tail=TERMINAL_TAIL_SIZE, the same
bound a tab switch loads; the route's existing tail cut marks longer
histories 'tail', so the banner still offers the unbounded pull. A
window no longer than the browser's buffer is not rewritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:05:16 -05:00
Devvyn bf73a84732 fix(docker): address git identity review 2026-09-25 22:27:26 +08:00
Devvyn 8d358aaa26 feat(docker): configure static git identity 2026-09-25 22:25:11 +08:00
Michael GrundbergandClaude Opus 5.5 a9b48320a3 fix(session): alert for an agent waiting on artifact comments
An agent that publishes an artifact arms a monitor for its comments and
ends its turn. Claude Code shows that on the footer as `1 Artifact
comment monitor`, and #473 put that chip on the list of background work,
so the session counted as watching and its idle prompt opened already
acknowledged. Unlike every other chip on the list, that monitor waits
on the user: the agent hears nothing until somebody comments.

Claude's `watchingLine` now refuses any footer that carries the chip,
through a lookahead over the whole row, so a shell running beside the
monitor cannot report the session as watching either.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 07:50:25 +02:00
DevvynandClaude Sonnet 5 95a3b87062 chore: drop changesets already released in 1.33.1
The pnpm and uv/uvx changesets describe work upstream shipped in 1.33.1
(#485, #487), so keeping them would repeat those notes in the next release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-25 11:14:47 +08:00
DevvynandClaude Sonnet 5 8cef31086b fix(docker): persist CLIs installed from Settings across container updates
The image sets NPM_CONFIG_PREFIX=/opt/codeman-cli, which is image content, so
Update-Codeman.sh discarded every npm-installed CLI (dsh, pi). In the Compose
container, POST /api/clis/:id/install now installs into ~/.local on the
persistent home mount, and ~/.local/bin is appended to the image PATH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-25 08:56:20 +08:00
Devvyn 55790964b7 Merge remote-tracking branch 'upstream/master' into feature/docker-uv-uvx 2026-09-25 08:30:56 +08:00
Codeman maintainer 5ae574374f docs(changelog): add the Thanks section to 1.33.1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:47:08 +02:00
Codeman maintainer 47f209bf0a chore: version packages (1.33.1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:20:42 +02:00
Ark0NandCodeman maintainer d81a4a76de feat(mobile): search box in the Select Case picker (#488)
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: Codeman maintainer <noreply@anthropic.com>
2026-09-24 23:08:53 +02:00
DevvynandClaude Sonnet 5 8841bcc93f feat(cases): refresh the case picker and add search to Manage (#483)
The Run bar's case picker only loaded /api/cases at page load, so folders
deleted or created on disk stayed listed until a reload. It now refetches
on open and every 5 seconds while open, repainting only when the list
changed and falling back to another case if the selected one was removed.

The Manage tab of Add Case gains a search box filtering by name or path.
Reorder arrows are disabled while a filter is active so a swap cannot
involve a hidden case.


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

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 23:08:47 +02:00
DevvynandClaude Sonnet 5 77ba41f8da feat(docker): install uv/uvx, libsecret-1-0 and pnpm (#487)
* fix(docker): install pnpm in the Compose server image

`dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run
menu's "DeepSeek - add a terminal profile" button failed with
`dsh: pnpm not found on PATH` (exit 127) on the server image. The agent
image already installs pnpm for the same reason (#352). Pin pnpm@12.6.0
in the runtime-writable CLI prefix and note it in the DeepSeek doc.

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

* feat(docker): install uv and uvx in server and agent images

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

* feat(docker): install libsecret-1-0 for the Azure DevOps MCP

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

* feat(docker): add sudo to the agent image

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

* feat(docker): install sudo with passwordless access for the agent user

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

* Revert "feat(docker): install sudo with passwordless access for the agent user"

This reverts commit b070c9ee65.

* Revert "feat(docker): add sudo to the agent image"

This reverts commit e98127a804.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 23:08:41 +02:00
Michael GrundbergandClaude Opus 5.5 b80d47aff8 feat(session): close sessions whose agent exited cleanly (#486)
* fix(cleanup): keep .claude-images while a sibling session uses the same dir

cleanupSession() recursively removes {workingDir}/.claude-images. That
directory belongs to the working directory rather than to the session, and
several sessions routinely share one case directory, so closing one session
deleted the pasted images a live sibling still referred to.

The removal now runs only when no other live session has the same working
directory. A session that is itself being cleaned up does not count as live,
so two sessions of one case closed together still remove the dir.

Split out ahead of the exited-agent sweep for Ark0N/Codeman#446, which closes
sessions unattended and would otherwise make the loss routine.

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

* feat(session): close sessions whose agent exited cleanly (#446)

Part 2 of Ark0N/Codeman#446. Part 1 records an exited agent as
SessionState.paneExit. A session whose agent the user ended with /exit is
now closed through cleanupSession(), the same path the X button takes, so
finished sessions stop piling up on the board. The lifecycle log records
the reason as "agent exited cleanly (status 0)", and the conversation stays
resumable from the Resume list.

shouldCloseCleanlyExitedSession() in the new pure module pane-exit-sweep.ts
holds the rule. It closes a session only when all of these hold:

- The exit status is an explicit numeric 0 with no signal. An absent status
  is how a SIGKILL presents on tmux 3.2a, so it counts as unknown and the
  row stays. A non-zero status or any signal also keeps the row, with the
  exit code on the tab.
- Two authoritative pane reads agreed on that exit.
  TmuxManager.getPaneExitReadCount() counts them, and a failed, empty or
  skipped read neither confirms nor resets the count.
- No start, attach or relaunch is running for the pane.
  Session.paneLifecycleInFlight covers _setupOrAttachMuxSession(), whose
  dead-pane branch revives an exited pane on purpose, and restartCli().

setPaneExit() already scopes paneExit to local mux-backed sessions, so
remote, docker and direct-PTY sessions are never closed.

planRebootRestore() now refuses a record whose persisted paneExit is a
clean exit. That covers an agent that exited just before a reboot, before
the sweep reached it. A crashed agent's record stays eligible, like its row.

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

* fix(web): show "exited" on the phone overview and desktop home rail (#446)

Part 1 of Ark0N/Codeman#446 taught the tab strip and the rich rail rows
to say that a session's agent has exited. The phone overview and the
desktop home rail still said "idle", beside a green or pulsing dot.

_mobileOverviewExit() in mobile-overview.js is now the one rule for all
three surfaces, and _sidebarRichRow() uses it as well. It changes what a
row shows and leaves the row's state alone, because the state still picks
the section and the sort order. An exited row gets an "exited" pill, a
neutral dot and row accent, and a duration measured from when the server
first saw the pane dead. A pending permission prompt or question still
wins, as it does on the tab.

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

* fix(cleanup): close the gaps review found in the #446 sweep and image guard

Four fixes from a dual review of Ark0N/Codeman#446 part 2.

- The .claude-images guard compares canonical paths, so a sibling that
  reaches the same directory through a symlink keeps it. Its comment used to
  say that case only missed a deletion; it caused one.
- A detached session counts as a live sibling. DELETE ?killMux=false removes
  it from the server's map while its pane keeps running, so the guard now
  reads persisted records too, and exempts only sessions being killed rather
  than every session in cleaningUp.
- A session being closed refuses startInteractive() and startShell(). The
  /interactive route awaits listener setup before the start, and a start
  that raced the close could launch a CLI in a tmux session whose record was
  then deleted. A failed close clears the mark again.
- The clean-exit sweep tries each exit once, keyed by session id and the
  exit's at stamp, so a close that fails is not retried and logged every
  two seconds.

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

* fix(session): keep a clean exit that lands within 10 s of a pane start (#446)

A CLI that prints a startup error ("not logged in", a bad profile, a config
error) and exits 0 used to lose its tab, and the error with it, about 4 s
after launch. The sweep now keeps any clean exit that lands within
CLEAN_EXIT_MIN_PANE_LIFETIME_MS (10 s) of the last start, attach or relaunch
finishing (Session.paneStartedAt, stamped when _withPaneLifecycle ends). The
row stays as "exited (0)" for the user to read and close.

Verified on an isolated instance: a shell that ran `exit 0` 2 s after start
kept its row, one that exited after 13 s was closed.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:18:07 +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
Devvyn d6c3386102 Revert "feat(docker): add sudo to the agent image"
This reverts commit e98127a804.
2026-09-24 22:19:20 +08:00
Devvyn 10c263a5b8 Revert "feat(docker): install sudo with passwordless access for the agent user"
This reverts commit b070c9ee65.
2026-09-24 22:19:13 +08:00
DevvynandClaude Sonnet 5 b070c9ee65 feat(docker): install sudo with passwordless access for the agent user
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-24 22:17:38 +08:00
DevvynandClaude Sonnet 5 e98127a804 feat(docker): add sudo to the agent image
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-24 22:17:22 +08:00
DevvynandClaude Sonnet 5 3e3a4612e6 feat(docker): install libsecret-1-0 for the Azure DevOps MCP
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-24 22:03:24 +08:00
DevvynandClaude Sonnet 5 a5283c565d feat(docker): install uv and uvx in server and agent images
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-24 21:04:18 +08:00
DevvynandClaude Sonnet 5 b46588f247 fix(docker): install pnpm in the Compose server image
`dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run
menu's "DeepSeek - add a terminal profile" button failed with
`dsh: pnpm not found on PATH` (exit 127) on the server image. The agent
image already installs pnpm for the same reason (#352). Pin pnpm@12.6.0
in the runtime-writable CLI prefix and note it in the DeepSeek doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-24 21:04:06 +08: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
208 changed files with 23482 additions and 766 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.34.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+3
View File
@@ -32,8 +32,11 @@ npm run typecheck # tsc --noEmit, strict mode
npm run lint
npm run format:check
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
npm run check:browser-excludes # every browser-driven test is kept out of `npm test`
```
`npm install` also installs a `pre-push` git hook that runs these static checks (about 10-40s, machine-dependent) and blocks the push if one fails. It skips itself when you push something other than the checked-out HEAD, or when the tree has uncommitted changes the checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own.
### Tests
```bash
+7
View File
@@ -34,6 +34,13 @@ jobs:
- name: Frontend JS syntax check
run: npm run check:frontend-syntax
# Asks `vitest list` what CI would actually collect, rather than matching
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
# (config/test-suites.ts) passes locally and dies in the test job with
# "browserType.launch: Executable doesn't exist".
- name: Browser-test exclusion check
run: npm run check:browser-excludes
- name: Format check
run: npm run format:check
+131
View File
@@ -1,5 +1,136 @@
# aicodeman
## 1.34.0
### Minor Changes
- 6aecc3b: ### Thanks
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
## 1.33.3
### Patch Changes
- f776ad8: ### Thanks
- @JDProfresh for rendering Markdown in the File Viewer (#503) through the chat's existing markdown pipeline and sanitizer rather than a second one, plus the Lines and Wrap toggles and the sanitizer fix that stops a document from clobbering `document.app`.
- @timkjr for bringing the Shell scroll-to-top history pull to the split view's second pane (#506), following #494's rules down to the back-off, with tests that fail on the code before each fix.
- @irisitymichaelgrundberg for the `#session=<id>` dashboard link (#507), so a page that keeps one Codeman window open can switch it between sessions without reloading it.
- @dignfei for handing focus back when the Command Palette or the Session Manager closes (#509), and for the six-overlay measurement that showed exactly which two were broken.
**Markdown files render in the File Viewer (#503).** Opening a `.md` or `.markdown` file now shows it as a document: headings, tables, code blocks with the same copy buttons as the chat, images relative to the file, and links to other documents that open inside the viewer. An `MD` pill switches back to the source, and Edit works from either view. Plain text gets a `Lines` gutter (never part of a copy) and a `Wrap` toggle, all three remembered per device. `.avif` images preview inline, and printed `.avif`/`.ico` paths open the viewer instead of the tail view. An in-workspace file path clicked in the terminal still opens the live tail view.
**Link a dashboard window to a session (#507).** An outside page, such as a task board, that keeps one Codeman window open can now switch it to a session by pointing it at `/#session=<id>`. Only the fragment changes, so the page stays loaded and the switch is an ordinary tab selection. A link to a session the dashboard does not list yet waits up to 30 seconds for it to appear and then shows "Session not found"; picking another tab, going Home or opening a web tab cancels the wait. Following a link does not count as looking at the session, so its idle alert stays armed. The fragment is documented in `docs/extending-codeman.md` and is now a stable surface under `docs/versioning-policy.md`.
**Escape no longer strands the keyboard (#509).** Closing the Command Palette or the Session Manager now hands focus back to whatever held it before they opened, usually the terminal, so you can keep typing without clicking first. An Escape pressed while neither is open changes nothing.
**Split view: a Shell Pane B scrolls back into tmux history (#506).** Wheel up at the top of a Shell session in the split view's second pane now pulls the most recent 1 MiB of its tmux history and keeps your place, the same as the primary pane since 1.33.2.
**Fixes applied while landing.** Markdown opened from an attachment card no longer resolves relative images and links against the workspace root, where they could show a missing image or open a different file of the same name; they render as their alt text and link text instead. Rendered files no longer turn every source line break into a hard break the way chat messages do, so a README wrapped at 80 columns reads as flowing paragraphs. Absolute-path links inside a rendered document open in that document's session. A disconnected Pane B keeps its "disconnected" marker as the last line even when the socket closes in the middle of a history pull. Closing the Session Manager through a row's "Switch to session" or "Open folder" no longer pulls focus back from the terminal to the header button.
## 1.33.2
### Patch Changes
- e439cf0: ### Thanks
- @aakhter for keeping web-tab events private to their owner in multi-user mode (#501), with end-to-end isolation tests that fail without the fix, and for the browser-test exclusion check and pre-push hook (#500), including the hooks-dir resolution that never writes outside the repo's own `.git/hooks`.
- @opticon454 for keeping CLIs installed from Settings across Docker container updates (#490) and for the static Git identity for the Docker images (#492).
- @timkjr for letting a Shell pane's scroll-to-top reach tmux history (#494), with tests that fail on the commit before each fix.
- @JDProfresh for tracking down why wheel and touch scrolling did nothing in Claude's default inline view (#498), with the tmux measurements that proved it.
- @irisitymichaelgrundberg for the follow-up that makes an agent waiting on artifact comments raise its alert again (#491).
**A tab stays busy while Claude waits for its own workers.** When Claude hands work to an ultracode workflow or background agents, it ends its turn with `✻ Waiting for 1 dynamic workflow to finish` and resumes by itself when they report back. The idle probe used to call that session idle for the whole wait, and at phone width nothing on screen changes for minutes. A new optional registry field, `capabilities.workDetect.awaitingLine`, names that closing row, and only the newest column-0 row directly above the composer counts, so the session goes idle normally once the follow-up turn ends.
**Prompts sent through the API are no longer left unsent.** A prompt posted to `POST /api/sessions/:id/input` without `useMux` was written into the pane in one piece, and Claude Code (measured on 2.1.283) takes a burst of about a hundred characters or more as a paste, so the trailing `\r` became a newline and the prompt sat on the composer while the route answered 200. Short prompts went through, which is why it looked random; Codex and OpenCode showed the same thing. A plain prompt (printable text plus exactly one trailing `\r`) now goes through tmux: the text is typed, Enter is pressed as its own key, and the server presses it again while the prompt is still on the composer. Raw frames (escape sequences, a bracketed paste, a line feed, a bare `\r`) and an explicit `"useMux": false` keep the direct write. The same fix reaches cron jobs in "Paste (direct)" input mode, which reported `prompt_sent` for a prompt that never left the composer: the text is written raw, Enter follows as its own write 300 ms later, and the session presses it again while the prompt is still unsent. A cron run with no session to write to now fails instead of reporting the prompt as sent.
**Scrolling works again in Claude's default inline view (#498).** Wheel and touch gestures were forwarded to every Claude 2.1.187+ session as mouse reports, but only Claude's fullscreen renderer (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`) listens for them, so in the default view scrolling did nothing. Codeman now forwards them only while Claude has mouse tracking switched on, and otherwise scrolls the terminal's own scrollback.
**Shell panes scroll back into tmux history (#494).** Scrolling to the top of a Shell pane now pulls the most recent 1 MiB of its tmux history, so output that arrived in a burst is reachable without pressing **Load full history**, which still loads the rest.
**An agent waiting on artifact comments alerts again (#491).** A session whose agent published an artifact and is waiting for somebody to comment on it now raises the normal idle alert and lands in NEEDS YOU, instead of being treated as busy with background work.
**Web-tab changes stay private in multi-user mode (#501).** The `webview:changed` event reached every connected user, exposing the ids of other users' web-tab creates, edits and deletes. It now carries the tab's owner and reaches that owner plus admins only. Single-user mode is unchanged apart from a new optional `owner` field on the event.
**Phone header tabs look like tabs (#504).** On phones every header tab is now a chip with a fill and a border, the Alt+N digit (a keyboard hint a phone cannot use) is hidden, names get 80px instead of 50px, and the strip fades at whichever edge still has tabs scrolled out of view.
**Docker: CLIs installed from Settings survive container updates (#490).** On the Compose deployment, CLIs installed from App Settings (DeepSeek, Pi and other npm-based CLIs) now go to `~/.local` on the persistent home mount, and `~/.local/bin` is on the image PATH, so recreating the container no longer discards them. Anything installed from Settings before this release has to be installed once more after the rebuild.
**Docker: a static Git identity for the server and agent images (#492).** Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` (or `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` / `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` on a bare-host install) and the identity is written to `/etc/gitconfig` in the server image and the Docker-case agent image. A half-set pair is refused on every build path. Both Docker changes edit `server.Dockerfile`, so the in-app updater asks Compose deployments to rebuild with `docker/Start-Codeman.sh` instead of updating in place.
**Contributor tooling (#500).** `npm run check:browser-excludes`, now a CI step, fails when a test that drives a real browser is still collected by `npm test`. `npm install` also installs a pre-push hook that runs the static CI checks before a push; it steps aside when the pushed ref is not HEAD or the tree has uncommitted changes the checks would read, and `CODEMAN_SKIP_PREPUSH=1 git push` skips it once.
**Fixes applied while landing.** A Shell pane's scroll-to-top (#494) no longer re-pulls the same window on every gesture once the browser's 50,000-row scrollback is full, and a pull that hit the byte cap no longer claims the older history is gone. The scroll-routing diagnostics (#498) now log whether Claude has mouse tracking on. The artifact-comment check (#491) also refuses a footer cut off in the middle of the chip. The pre-push hook (#500) steps aside when `npm` is not on PATH, as in some GUI git clients, instead of blocking every push. The Docker Git identity error (#492) names the two variables to set. New tests pin the image PATH order, the identity on both agent-image build paths, and the `?full=1&tail=` terminal route.
## 1.33.1
### Patch Changes
- ### Thanks
- @irisitymichaelgrundberg for closing sessions whose agent exited cleanly (#486), built carefully around every way a pane exit can lie (a SIGKILL with no status, a single misread), with the `.claude-images` guard split into its own commit as asked.
- @opticon454 for the live-refreshing case picker and Manage search (#483), and for the uv/uvx, libsecret and pnpm additions to the Docker images (#487, #485).
**Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
**Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show.
**An oversized paste no longer jams a session's input (#484).** A single input over the 64 KiB frame limit used to be refused by both transports, retried every 2 s forever, block every later input for that session and come back from localStorage on each reload. Pastes over the limit are now split into in-limit frames delivered in order (up to 1 MiB; larger ones are refused with a toast and never queued), a refused frame is dropped instead of retried, frames persisted by an older build are pruned on load, and the WebSocket answers an oversized frame with an explicit `too_large` error instead of silence.
- 8841bcc: Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case.
- 8841bcc: The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one.
Thanks @opticon454.
- 77ba41f: Install `uv` and `uvx` in the Compose server image and the agent image, so MCP servers launched with `uvx` (such as the Nginx Proxy Manager MCP) can be enabled by Codex instead of failing with `uvx` not found. Both images also install `libsecret-1-0`, the native library the `keytar` dependency of the Azure DevOps MCP (`@azure-devops/mcp`) needs; without it the server crashes before answering the MCP initialize handshake.
The Compose server image now also carries `pnpm`: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed with `dsh: pnpm not found on PATH` there. Because this release changes `server.Dockerfile`, the in-app updater asks Compose deployments to rebuild the image (`Update-Codeman.sh`) rather than applying it in place.
Thanks @opticon454 (#487, #485).
## 1.33.0
### Minor Changes
- CLI management from Settings (#476, finishing the CLI registry work from #343). `~/.codeman/clis.json` used to be hand-edit only; with the new opt-in `cliManagementEnabled` switch (synced, default OFF) App Settings → Agents & CLIs can enable or disable any CLI, install a missing stock CLI with its vetted install command, and add, edit or remove custom CLIs. Six new endpoints back it (`GET`/`POST /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id`), documented in `docs/api-reference.md`. Every write is refused while the switch is off, is admin-only in multi-user mode, is serialized on one queue, and refuses to overwrite a `clis.json` that does not parse or has group/world permission bits. A custom entry is re-validated through the same schema as the stock ones and its install text is never executed. `shell` cannot be disabled. The Run menu and the welcome screen are now built from the enabled catalogue, so the welcome screen also offers Codex, Shell and any custom CLI, and the stock Claude entry is labelled "Claude Code".
Models: Opus 5.5 (`claude-opus-5-5`, 1M context capable) is offered in App Settings → Models and in task routing (#480).
Self-update: on a macOS `launchd-daemon` install, a Homebrew node upgrade could leave `update-status.json` stuck at `queued`, which made every later update fail with "An update is already in progress." The updater now falls back to `node` on PATH when the server's own node binary is gone, and an in-flight status that has not been written for 15 minutes is failed on the next read. A graceful shutdown that hangs is now force-exited after 10 s (and the launchd updater SIGKILLs a server that has not exited after 30 s), so launchd can start the new build instead of leaving the service down (#478). Both fixes protect updates that start FROM this release.
Session Manager (Cmd+K): rows keep their `mode`, `claudeSessionId` and `resumeId`, so the ⋯ menu's Resume session relaunches a Codex row as Codex on its own conversation, and the mode badge shows as it does on the home list (#477).
Maintainer fixes applied while landing #457: renaming a tab to the name it already has (the Session Options field saves on blur) is now a no-op, so it no longer pins the placeholder as the `/resume` title again; Docker sessions skip the transcript title sync, since their transcript lives in the container; and the agent skill's messaging examples no longer use a `w<N>-` name as the peer name.
Tests: the suite strips every inherited `CODEMAN_*` variable, so running it inside a Docker Compose deployment no longer writes into the deployment's real case root (#479).
### Thanks
- @opticon454 for CLI management (#476), the last piece of the CLI registry, with every review item answered in one round, and for splitting the test isolation fix out into #479.
- @shenlvkang-collab for the `/resume` title fix (#457) and the careful diagnosis behind it.
- @julian3xl for the Session Manager row fix (#477), their first contribution.
### Patch Changes
- 69a7128: fix(sessions): stop pinning the `w1-myapp` placeholder as Claude's session title. Local Claude spawns passed the tab name as `--name`, which is also the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own, so every conversation of a case showed up in `/resume` as the same `w1-myapp` and none got a generated title. Only a name the user chose is pinned now; placeholder and auto-named tabs let Claude title the conversation again. Renaming a Claude tab also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (a tab that was spawned with `--name` keeps re-appending its own title until its next respawn, so the rename wins from then on). Orchestrators that rely on a fixed peer name should give workers a descriptive `sessionName` rather than a `w<N>-` one.
## 1.32.1
### Patch Changes
+30 -16
View File
@@ -34,6 +34,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- To land a commit on master **without** switching branches (which would yank the tree out from under the other session): `git push origin HEAD:master` then `git branch -f master HEAD`. Never `git checkout master` to "fix" it.
- **Never `git add -A`/`git add .`** — stage explicit paths. A sweep will pick up another session's WIP.
- Another session's broken WIP can block `npm run build`, since `tsc` is the first step and the build gates on it. That is not your bug to fix. ⚠️ `tsc` still EMITS on type errors, so a failed `npm run build` leaves a rebuilt `dist/index.js` compiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blocked `tsc`, run the asset stage of `scripts/build.mjs` (everything after the `tsc`/`chmod` lines is independent of it).
- **A pre-push failure in a file you did not touch is another session's WIP.** Push with `CODEMAN_SKIP_PREPUSH=1 git push` and leave it alone. (The hook already skips itself when the tree has uncommitted changes in a path it checks, so this mostly happens once the other session has committed.)
## CRITICAL: Always Test Before Deploying
@@ -77,7 +78,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.32.1 (must match `package.json`)
**Version**: 1.34.0 (must match `package.json`)
## Project Overview
@@ -112,6 +113,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) |
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
| Frontend JS syntax check | `npm run check:frontend-syntax` (`scripts/check-frontend-syntax.mjs`; runs in CI) |
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
| Excluded-suite runners | `npm run test:browser` · `npm run test:mobile` · `npm run test:perf` · `npm run test:all` (everything, environmental failures included) — see Testing |
| Production start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` |
@@ -120,7 +123,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 9 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 14 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
@@ -128,13 +131,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
## Common Gotchas
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
@@ -203,9 +207,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer all through a turn, and its working line (`✻ Actualizing… (13m 23s · …)`) is invisible to `SPINNER_PATTERN`, keyword lists and the raw stream. `_confirmIdle()` (session.ts) requires the pane to go quiet AND the SCREEN (`capturePaneText()` + the working-line pattern) to agree; a sustained run of repaints (`session-activity.ts`) marks a turn as started. ⚠️ The composer glyph and working line are per-CLI registry DATA (`capabilities.workDetect`), never Claude constants; a CLI declaring neither falls back to Claude's pair. ⚠️ `workingLine` is config-supplied and runs on the PTY hot path, so it must compile through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()` (ReDoS guard; null, not throw). → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
⚠️ **A turn that ENDED waiting for its own workers is working, not idle.** Claude closes such a turn with `✻ Waiting for 1 dynamic workflow to finish` (background agents / ultracode) and resumes by itself; `capabilities.workDetect.awaitingLine` makes the idle probe count it as work. ⚠️ Claude never redraws that row, so it stays on screen after the workers finish: test it ONLY as the newest column-0 row above the composer (`isAwaitingWorkers()`), never pane-wide and never on the stream. → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`.
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via `cleanupSession()` (`pane-exit-sweep.ts`): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (`paneLifecycleInFlight`) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
**Dead-pane respawn resume pin** (`_buildRespawnPaneOptionsWithResumePin()`, session.ts): recovering a dead pane, like a custom-model `restartCli()`, must pin the conversation or claude refuses the reused `--session-id`. The pin takes the first transcript-backed candidate (chain tail, launch seed, own id), never `_claudeSessionId`, adds nothing when none is backed, and is never applied to remote or docker sessions. → [architecture-invariants#dead-pane-respawn-the-resume-pin](docs/architecture-invariants.md#dead-pane-respawn-the-resume-pin)
@@ -227,9 +233,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Docker cases**: a case can point at a **container** running any CLI mode inside it, a **LOCATION OVERLAY on cases, never a `SessionMode`**. One long-lived container **per case**, shared by its sessions: killing a session kills only its in-container tmux, **never** `docker stop` while siblings remain. The workspace is bind-mounted at the **same absolute path**. Credentials are **seeded**, never shared RW. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** A drifted config (label hash) REFUSES the launch. ⚠️ An **adopted** container (`DockerCase.owned === false`) is only `exec`ed into: never create, start, stop, restart, remove, `docker commit` or `docker pause` it; fail closed. Test `owned === false`, never truthiness. ⚠️ Apply `owned` AFTER `dockerConfigHash`. ⚠️ Run modes come from the CONTAINER (`availableModes`), and a failed probe is normal for an OWNED case. ⚠️ Root exec user drops the bypass flag via the registry's `overlays.docker.rootCommand`, never a branch. ⚠️ Adoption is admin-only in multi-user mode. ⚠️ Loopback prod needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for in-container hooks. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md`
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries (read-only). → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
@@ -239,17 +245,19 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles)
**MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md`
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**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.
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. BACKEND ONLY: no frontend calls these routes yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
@@ -265,7 +273,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and Shell loads the rest only via **Load full history**, never on ordinary scroll. ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`SplitTerminalPane._pullHistory`, terminal-split.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
@@ -275,7 +283,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY**; ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
@@ -290,6 +298,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md` stays OUT of `FILE_PREVIEW_EXTENSIONS`: only an IN-WORKSPACE `.md` path clicked in the TERMINAL keeps the tail viewer (live follow); the Files panel, chat paths, out-of-workspace terminal paths and attachment cards all reach `openFilePreview()` and render it. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
@@ -312,7 +322,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
@@ -320,6 +330,10 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Tab grouping by state** (`tabGrouping`, per-device, default `state`; Discussion #426 option C): the tab list splits into needs you / waiting / working / idle, most urgent on top: rows in the desktop header strip, sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. Classification is `_mobileOverviewState()` + `_mobileOverviewExit()`; the fold and the order bands are pure in `CodemanTabTriage` (constants.js). ⚠️ It is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (Alt+N, drag and the keyboard walk keep reading tab order). ⚠️ Drag only reorders within a group (`_isTabDropAcrossTriageGroups()`). ⚠️ Named groups in the vertical rail win: triage is off whenever `_projectTabGroups()` is non-null. ⚠️ `'none'` must leave no trace (no headings, no inline order, no `tabs-triage` class). Tests: `test/tab-triage.test.ts`. → [architecture-invariants#tab-grouping-by-state](docs/architecture-invariants.md#tab-grouping-by-state)
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `tiles`; Discussion #426 option G): `tiles` / `compact` (pill + sparklines + plan rings) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (sparklines, tile words, rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](docs/architecture-invariants.md#header-stats-styles)
**Phone overview home screen** (`mobile-overview.js`, per-device `mobileOverviewEnabled`, default ON): under 600px the "C" logo shows NEEDS YOU / CURRENT / PAST SESSIONS instead of the welcome overlay, branched in `showWelcome()`/`hideWelcome()` via width-driven `shouldUseMobileOverview()`. ⚠️ The container ships `hidden` and only this module removes it: never give `.mobile-overview` a bare `display` rule (desktop does not load `mobile.css`). ⚠️ The split Run button must carry the toolbar's own classes (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) and mobile.css must set no `background`/`color` on it; row status must mirror the session-tab alert language. PAST rows resume through the shared `resumeHistorySession()`. Status pills carry `data-i18n-skip`. → [architecture-invariants#phone-overview-home-screen](docs/architecture-invariants.md#phone-overview-home-screen)
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](docs/architecture-invariants.md#desktop-home-tab-rail)
@@ -328,7 +342,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
@@ -360,7 +374,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key)
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](docs/architecture-invariants.md#dismissing-the-on-screen-keyboard)
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
@@ -372,7 +386,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
@@ -423,7 +437,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
## State Files
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
+4
View File
@@ -19,6 +19,10 @@
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
</p>
<p align="center">
⭐ <strong>Like Codeman? <a href="https://github.com/Ark0N/Codeman">Give it a star on GitHub!</a></strong> It takes one click and helps more people find the project. ⭐
</p>
<p align="center">
<strong>English</strong> &bull; <a href="README.zh-CN.md">简体中文</a>
</p>
+4
View File
@@ -23,6 +23,10 @@
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
</p>
<p align="center">
⭐ <strong>喜欢 Codeman?<a href="https://github.com/Ark0N/Codeman">在 GitHub 上给它点个 Star 吧!</a></strong>只需轻点一下,就能帮助更多人发现这个项目。⭐
</p>
<p align="center">
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
</p>
+1 -1
View File
@@ -1,7 +1,7 @@
[
{
"id": "claude",
"label": "Claude",
"label": "Claude Code",
"shortBadge": "CC",
"enabled": true,
"order": 0,
+5
View File
@@ -20,6 +20,7 @@
*/
export const BROWSER_TEST_GLOBS = [
'test/tab-rail-resize.browser.test.ts',
'test/tab-activation.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
@@ -31,8 +32,12 @@ export const BROWSER_TEST_GLOBS = [
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
'test/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
];
/**
+6
View File
@@ -15,6 +15,12 @@ TZ=Australia/Perth
# this value rebuilds the image with a matching account.
CODEMAN_RUNTIME_USER=codeman
# Optional Git identity for commits made by Codeman and Docker-case agents. These values
# are written to each image's system Git configuration when it is rebuilt, so
# deployments can configure a consistent default. Set both values together.
# GIT_USER_NAME=
# GIT_USER_EMAIL=
# Required. Persistent Codeman application data, CLI credentials, and session
# state are stored here on the host and mounted at the runtime account's home
# directory in the container.
+21
View File
@@ -67,6 +67,27 @@ two volumes are removed, by name within this Compose project; any volume a
`docker-compose.override.yml` adds is left alone, and application data and
case workspaces are host bind mounts, never touched either way.
## Git commit identity
Set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` before rebuilding:
```sh
GIT_USER_NAME='Your Name'
GIT_USER_EMAIL='you@example.com'
```
Compose passes the values to the Codeman server build, and to the server process
when it builds Docker-case agent images. Both images write the pair to Git's
system configuration during their build, so commits retain the same identity
after a container or agent image is recreated. Set both values together; an
image build with only one value fails rather than using a partial identity. An
identity already present in `CODEMAN_APPDATA_PATH`'s `~/.gitconfig` overrides
the server image's system-level default.
Run `bash docker/Start-Codeman.sh` after changing the server values. Rebuild an
existing agent image with `node scripts/build-agent-image.mjs --no-cache` in the
server container, then recreate any Docker cases that should use it.
## Private repositories (GitHub and Azure DevOps)
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
+20
View File
@@ -17,6 +17,7 @@ FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
libsecret-1-0 \
tmux \
ripgrep \
curl \
@@ -126,6 +127,10 @@ RUN set -eux; \
# A different order is a different RUN string, which is a different layer hash and
# so a needless cache miss between a bare `docker build` and a scripted one.
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
RUN npm install -g ${CLI_NPM_PACKAGES} \
&& npm cache clean --force
@@ -249,6 +254,21 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
&& chgrp -R 0 /home/agent \
&& chmod -R g=u /home/agent
# Docker cases have a fresh, container-owned home directory. Declare the
# optional identity here so changing it invalidates only this final layer, then
# configure Git's system defaults. A user-level config still takes precedence.
ARG GIT_USER_EMAIL=
ARG GIT_USER_NAME=
RUN set -eux; \
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
exit 1; \
fi; \
git config --system user.name "${GIT_USER_NAME}"; \
git config --system user.email "${GIT_USER_EMAIL}"; \
fi
USER agent
WORKDIR /home/agent
+6
View File
@@ -7,6 +7,8 @@ services:
dockerfile: docker/server.Dockerfile
args:
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
GIT_USER_NAME: ${GIT_USER_NAME:-}
PGID: ${PGID:-1000}
PUID: ${PUID:-1000}
image: ${CODEMAN_IMAGE}
@@ -32,6 +34,10 @@ services:
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
# Passed through only so Codeman can use the same identity when it builds
# the Docker-case agent image.
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: ${GIT_USER_EMAIL:-}
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: ${GIT_USER_NAME:-}
# Extra Host-header allowlist entries for a reverse-proxied deployment
# (docker/README.md, "Reverse-proxy host allowlist"). Optional, so it
# defaults to empty rather than requiring a line in every .env.
+31
View File
@@ -39,6 +39,7 @@ RUN apt-get update \
curl \
g++ \
git \
libsecret-1-0 \
make \
openssh-client \
procps \
@@ -212,13 +213,28 @@ RUN set -eux; \
# minimal image of this exact shape). The four CLIs live only in this prefix,
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
# system directories for the root part of the start.
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
ENV PATH=$PATH:/opt/codeman-cli/bin
# CLIs installed at runtime (Settings -> CLIs, npm redirected to ~/.local by installEnv()) live on the
# persistent home mount, so they survive a container recreate. Appended for the same reason as above.
ENV PATH=$PATH:/home/${CODEMAN_RUNTIME_USER}/.local/bin
# pnpm is not an agent CLI: it is here because `dsh plugin` (DeepSeek Harness, which
# this image leaves to be installed at runtime, see SERVER_INTENTIONAL_OMISSIONS in
# test/docker-agent-image-coverage.test.ts) spawns a literal `pnpm` with no npm
# fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed
# with `dsh: pnpm not found on PATH` (exit 127) on this image. The agent image
# already carries it for the same reason (#352). It lives in the same
# runtime-writable prefix as the CLIs, so a session can update it in place.
RUN npm install --global \
@anthropic-ai/claude-code@2.1.258 \
@google/gemini-cli@0.58.0 \
@openai/codex@0.152.1 \
opencode-ai@1.18.26 \
pnpm@12.6.0 \
&& npm cache clean --force
# Keep the web server and every local Codeman session unprivileged. PUID and
@@ -286,6 +302,21 @@ EXPOSE 3000
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0755 /usr/local/bin/entrypoint.sh
# Declare the optional identity immediately before configuring it so a change
# invalidates only this final layer. This is declarative setup: a persisted
# ~/.gitconfig in CODEMAN_APPDATA_PATH still overrides the system-level values.
ARG GIT_USER_EMAIL=
ARG GIT_USER_NAME=
RUN set -eux; \
if [ -n "${GIT_USER_NAME}" ] || [ -n "${GIT_USER_EMAIL}" ]; then \
if [ -z "${GIT_USER_NAME}" ] || [ -z "${GIT_USER_EMAIL}" ]; then \
echo 'Git user name and email must both be set when configuring Git identity' >&2; \
exit 1; \
fi; \
git config --system user.name "${GIT_USER_NAME}"; \
git config --system user.email "${GIT_USER_EMAIL}"; \
fi
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "dist/index.js", "web"]
+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`.
+57
View File
@@ -311,6 +311,15 @@ worker's prompt but never submitted, and the wait then runs its full timeout on
turn that never started. Verified live; this is the most common silent failure on
this endpoint.
A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is
delivered through tmux even without `useMux`: the text is typed, Enter is pressed as
a separate key, and the server re-presses Enter while the prompt is still visibly
sitting on the composer. Written straight into the pane in one piece, a prompt of
about a hundred characters or more is taken as a paste by Claude Code, its `\r`
becomes a newline, and the prompt stays unsent (measured on 2.1.283). Any other
input (escape sequences, a bracketed-paste frame, a line feed, a bare `\r`) keeps
the raw write, and an explicit `"useMux": false` forces it.
```bash
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
-H 'Content-Type: application/json' \
@@ -691,6 +700,54 @@ 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. |
## MCP server sync
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
| Method | Path | Body | Notes |
| ------ | --------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/mcp-sync` | none | Dry run. Same result shape as `POST`, with `applied: false`; nothing is written. |
| `POST` | `/api/mcp-sync` | none | Adds each server a CLI is missing to that CLI's config file. Never edits or removes a server. `500` on an unexpected error. |
Result (`data`):
- `applied` — `false` for the dry run.
- `targets[]` — one per enabled CLI that declares an MCP config: `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`); see `docs/cli-registry.md`.
- `conflicts[]` — names defined differently by different CLIs. Existing definitions are kept; the first CLI's is copied where the name is missing.
- `disabled[]` — names left out because every definition is switched off in its own CLI (codex `enabled = false`, opencode `enabled: false`, antigravity `disabled: true`).
- `unsupported[]` — labels of enabled agent CLIs with no known MCP config file (nothing is guessed).
- Only installed CLIs are listed: one that is not installed is left out, as a supported CLI that is not installed reads `absent`.
The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`.
## Webhook notifications
Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode.
| Method | Path | Body | Notes |
| ------ | -------------------- | -------------------------------------------- | ----- |
| `GET` | `/api/webhook` | none | `{ enabled, kind, scope, hasUrl, urlMasked, lastResult }`. `urlMasked` is scheme + host only. `lastResult` is the last delivery (`ok`, `status?`, `error?`, `at`) or `null`. |
| `PUT` | `/api/webhook` | `{ enabled?, kind?, scope?, url? }` (strict) | `kind`: `ntfy` \| `slack` \| `discord` \| `generic`. `scope`: `attention` (skip "response complete") \| `all`. An absent `url` keeps the saved one; `""` clears it. `400` for a non-http(s) URL, `user:pass@`, a link-local or cloud-metadata target, or enabling with no URL. |
| `POST` | `/api/webhook/test` | none | Sends one message with the saved config, even while disabled. `200` with `data.ok` telling whether the webhook accepted it; `400` if no URL is saved. |
Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.
## Voice dictation
Browser dictation transcribed through this server's Claude Code login, i.e. the
File diff suppressed because one or more lines are too long
+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.
+43 -5
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
@@ -36,8 +46,9 @@ interface CliEntry {
launch: CliLaunch; // the structured argv template
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
capabilities: CliCapabilities; // what every call site reads instead of the id
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines? } — how
// this CLI's pane shows work, and how it shows work it started in the background
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? }
// — how this CLI's pane shows work, work it started in the background, and a turn
// that ended waiting for workers it will resume from
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
}
```
@@ -46,7 +57,7 @@ interface CliEntry {
### Regexes that come from config
Three capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine` and `capabilities.workDetect.watchingLine`. All three go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
Four capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`. All four go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
@@ -56,6 +67,9 @@ agents` while a monitor, a backgrounded shell or a cloud session is live. Codema
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
so a pane waiting for its own background work never raises an alert a human cannot answer.
Group 1 is the label, and a CLI that declares no pattern reports no background work.
Claude's Artifact comment monitor is the one chip that does not count. It waits for a human
to comment on a page the agent published, so Claude's pattern refuses any footer that
carries it, and the idle alert goes out as usual.
Two CLIs declare such a row today, and they put it in different places. Claude writes its
chip on the last row of the screen, so it keeps the default one-row window and anchors on
@@ -66,6 +80,18 @@ entry declares `watchingLines: 3` and matches that row end to end. Both were mea
against live panes rather than read out of a binary, which is the standard for adding a
third.
`awaitingLine` covers the quiet pane that is neither idle nor watching: a turn that ENDED
to wait for workers the CLI will resume from by itself. When background agents or an
ultracode workflow are still running at turn end, Claude closes the turn with
`✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, and a pane
showing that row counts as working. ⚠️ Claude renders the row once and never redraws it, so
the words are still on screen after the workers report back and the follow-up turn ends.
The pattern is therefore never run over the whole pane: `isAwaitingWorkers()`
(`session-activity.ts`) walks up from the composer past blank, framed and indented rows and
tests only the first row that starts in column 0, which is the newest transcript row. Claude
starts its own rows in column 0 and the agent's prose never does, so the anchor also keeps an
agent from holding its own session busy.
That label is the one value in the registry that an AGENT can influence, because it comes off
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last few
@@ -92,6 +118,10 @@ sure its row is one the agent cannot write.
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
## The newline chord
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
## Arg-template safety
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
@@ -149,7 +179,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.
@@ -223,6 +253,14 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
4. Only if it cannot install with a plain `npm install -g <pkg>`: give it a layer in `docker/agent.Dockerfile` and set `discovery.install.agentImageLayer: { kind: 'dedicated', reason }` on its entry in `stock.ts`. `test/docker-agent-image-coverage.test.ts` requires both, so an exclusion cannot quietly become an omission. An entry with no `npmPackage` needs only the Dockerfile layer, since it never enters the shared npm layer in the first place.
5. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
## MCP server sync
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity; every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`) and gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
## See also
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
+3 -1
View File
@@ -53,7 +53,9 @@ that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 w
surfaces that same line as the install error. `npm install -g pnpm` (or
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
alongside `dsh`.
alongside `dsh`. The Compose server image (`docker/server.Dockerfile`) does not
ship `dsh`, since it is installed at runtime, but it does ship pnpm so the UI
button works there too.
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
margin the most used community TUI, it is MIT, and it implements the status
+2
View File
@@ -83,6 +83,8 @@ Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.jso
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
## Quickest path: one-click "Run in Docker"
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
+2
View File
@@ -6,6 +6,8 @@ For the Compose configuration, environment settings, storage migration, and macv
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
CLIs installed from **App Settings → Agents & CLIs → CLI management** (DeepSeek Harness, Pi, and the other npm-based ones) go to `~/.local` on the `CODEMAN_APPDATA_PATH` mount, so they survive an image rebuild and a container recreate. Releases up to 1.33.1 installed them into the image instead, so a CLI installed from Settings on one of those has to be installed again once after the rebuild. The same applies to a hand-run `npm install -g` inside a session: it writes to the image prefix (`/opt/codeman-cli`) and is lost on the next rebuild, so use `npm install -g --prefix ~/.local <package>` instead.
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
## Prerequisites
+36
View File
@@ -338,6 +338,42 @@ codeman ralph start|stop|status|reset codeman users add|passwd|list
codeman status | list | attach <path> codeman doctor
```
### Opening a session from your own page
To send someone from your page to one session, link to the dashboard with the
session id in the fragment, as in `http://127.0.0.1:3000/#session=<id>`. The
dashboard selects that tab when it loads. It also removes the fragment from its
own URL, so a later link to the same session still counts as a change.
Keep reusing one named window to make later links fast:
```js
window.open(`${codeman}/#session=${encodeURIComponent(id)}`, 'codeman');
```
When that window already shows the dashboard, only the fragment differs. The
browser therefore keeps the page loaded, and the dashboard switches tabs without
reloading it. A session the window has shown before appears at once. A session
your page has only just created may not be listed yet, so the dashboard waits
for its `session:created` event and selects it then. That wait lasts at most 30
seconds: a link whose session never appears (a closed session, a typo, or in
multi-user mode another user's session) is dropped with a "Session not found"
notice. Clicking another tab, going Home or opening a web tab also ends the
wait, so a session that turns up later never takes the screen from the person.
When your page holds the window reference (`const win = window.open(...)`),
prefer `win.location.replace(url)` for later links: it still fires `hashchange`
without a reload, but adds no history entry, so Back in the dashboard window
does not turn into a silent no-op.
Following a link does not count as someone looking at the session, so it
leaves the session's idle alert in place. The alert clears when the person
clicks the tab or types into the session. A link to a session that is popped
out into its own window asks that window to come forward, as clicking its tab does.
A link to `/session/<id>` opens a page showing that session alone, and that
page loads from scratch for every link.
## Seam 4: Hooks
Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session.
+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).
+10
View File
@@ -532,6 +532,16 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
---
## 10c. Webhook notifications (outbound channel)
Opt-in and off by default: the server POSTs the Web Push events (permission prompts, questions, idle, errors, respawn blocked, crash-loop breaker, Ralph completion) to one URL an admin configures, formatted for ntfy, Slack, Discord or generic JSON. Source: `src/webhook-notify.ts`, routes in `src/web/routes/webhook-routes.ts`. User guide: [`wiki/Notifications-And-Approvals.md`](wiki/Notifications-And-Approvals.md).
- **A second server-side outbound channel through the web-tab egress guard (§10b).** Delivery goes through `webviewFetch`, so link-local and cloud-metadata targets are refused at save time and again on the RESOLVED address at connect time; redirects are not followed (`redirect: 'manual'`) and each send is bounded by a 5 s timeout. Loopback and RFC1918 stay allowed on purpose (a self-hosted ntfy is the point), so **Send test** works as a blind reachability probe (status, refused or timed out, never a response body) for whoever may call it. Web tabs already give that caller full LAN reach with bodies, so nothing new is exposed.
- **The URL is a bearer secret** (anyone holding a Slack or Discord webhook URL can post as it). It lives in `~/.codeman/webhook.json` (0600, tmp+rename), is kept out of `settings.json` (which every logged-in user reads through `GET /api/settings`), is never returned (`GET /api/webhook` gives scheme + host only), and never appears in a log line, a delivery result or an error message.
- **It carries session data to a third party.** Titles and bodies include session names, tool names and error text, all agent- or user-controlled, so Discord gets `allowed_mentions: { parse: [] }` and Slack's `& < >` are escaped: agent output cannot ping a channel. In multi-user mode all three routes are admin-only and the channel is instance-wide: it receives every user's session events, the same reach an admin's own Web Push has, which means non-admins' session details leave the box at the admin's choice.
---
## 11. Quick reference
| Env / flag | Effect |
+4
View File
@@ -40,6 +40,10 @@ A **MAJOR** bump is required to break any of these after 1.0:
optional fields, new error codes, new SSE events) are non-breaking; breaking
changes ship under a new prefix (`/api/v2`). The unversioned `/api/...` alias
is kept working for the bundled UI.
5. **The dashboard's `#session=<id>` link.** Opening the dashboard URL with a
`#session=<id>` fragment selects that session if this client can see it. The
fragment name and that meaning are stable; see
[Opening a session from your own page](extending-codeman.md#opening-a-session-from-your-own-page).
## What SemVer does NOT cover (internal surfaces — may change in any release)
+4 -1
View File
@@ -76,7 +76,7 @@ output. The other CLIs expose no equivalent.
| Read My Mind | Yes | No |
| Ralph loop and its task tracker | Yes | No |
| Subagent and team windows | Yes | No |
| Model, effort, and ultracode controls | Yes | No |
| Model, effort, advisor, and ultracode controls | Yes | No |
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
| The bundled agent skill | Yes | No |
@@ -96,6 +96,9 @@ The defaults you will care about, all under **App Settings**:
- **Effort** (`low` through `max`) or **ultracode** for dynamic multi-agent workflows. Also
a soft default: `/effort` overrides it any time. Effort is deliberately not passed as an
environment variable, because that would hard-lock it and block in-session switching.
- **Advisor** (Sonnet, Opus or Fable): a stronger model Claude consults at decision points,
via Claude Code's [advisor tool](https://code.claude.com/docs/en/advisor). Also a soft
default: `/advisor` switches it or turns it off inside the session.
- **Startup permission mode** (Agents & CLIs section). The default is
`--dangerously-skip-permissions`, which is why the security model matters. You can switch
new sessions to Anthropic's classifier-guarded `auto` mode, normal prompting, or an
+7
View File
@@ -42,10 +42,17 @@ npm run typecheck
npm run lint
npm run format:check
npm run check:frontend-syntax
npm run check:browser-excludes
npm test -- test/<file>.test.ts # one file, the normal way
npm run test:ci # the full CI sweep
```
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
machine-dependent) and blocks a push that would fail them. It skips itself when you push
something other than the checked-out HEAD, or when the tree has uncommitted changes the
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
`pre-push` hook of your own is never overwritten.
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
suites that need a live server, Chromium, and environment-specific baselines; they hang or
fail on a normal machine. `test:ci` is the honest "run everything".
+6
View File
@@ -147,6 +147,12 @@ in `docker-compose.override.yml`), then rebuild the image with `--no-cache`.
`docker/README.md` ("Private repositories") has the details and the matching switches for
the server image.
To give agents a fixed Git commit identity, set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and
`CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together in that same environment (in the Docker
deployment, set `GIT_USER_NAME` and `GIT_USER_EMAIL` in `docker/.env` instead, which feeds
both images). An existing `codeman/agent:base` only picks it up after a `--no-cache` rebuild
and recreated case containers; `docker/README.md` ("Git commit identity") has the details.
## Isolation
Every container runs hardened by default:
+2
View File
@@ -144,6 +144,8 @@ curl -s "$API/api/sessions" | jq '.data[].name' # live sessions
curl -s "$API/api/sessions/unified" | jq # live + historical, deduped
curl -s "$API/api/subagents" | jq # background agents
curl -s "$API/api/search?q=deploy" | jq # cross-session search
curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on)
curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove
# with ID set to a session id:
curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek)
+55 -3
View File
@@ -7,11 +7,12 @@ opening the session.
## The signals, cheapest first
| Surface | Reaches you | Default |
| ---------------------- | ------------------------------------------------- | ------- |
| ------------------------------ | ------------------------------------------------ | ------- |
| Tab alert | While the dashboard is open | On |
| Browser title flash | Another tab in the same browser | On |
| Desktop notification | Another window on the same machine | Opt-in |
| Push notification | Anywhere, even with no tab open | Opt-in |
| Webhook (ntfy, Slack, Discord) | Anywhere, with no browser or subscription at all | Opt-in |
| Approvals Inbox | One queue across every session | Opt-in |
| Phone overview | Phone home screen, NEEDS YOU section | On |
| Away Digest | Afterwards, as a summary | Opt-in |
@@ -60,6 +61,51 @@ Setup:
Once subscribed, a blocking prompt reaches your phone even from a locked screen.
## Webhooks: ntfy, Slack, Discord
**Opt-in, off by default. One channel for the whole server.**
Push needs a browser that subscribed once. A webhook needs nothing on the client side: the
server itself posts each alert to an ntfy topic, a Slack or Discord incoming webhook, or any
URL as plain JSON. That makes it the option for a headless box nobody has opened in a browser,
and for a team channel.
It carries the same events as push: permission prompts, questions, idle sessions, session
errors, blocked respawns, a stopped crash loop and Ralph task completion. "Response complete"
is included only when **Which events** is set to **Everything**; the default, **Needs
attention**, skips it. A session that is watching its own work stays quiet here too.
Setup, in **App Settings → Notifications → Webhook**:
1. Pick the **Service**. ntfy gets a title, a priority and a tag per urgency; Slack and
Discord get a bold title line; **Generic JSON** posts `{ event, title, body, urgency,
sessionId, sessionName, host, at }`.
2. Paste the **Webhook URL** and turn on **Send alerts to a webhook**.
3. Press **Save**, either the group's own button or the main Settings Save, then **Send test**.
Send test saves anything you changed first, so it always tests what is on screen.
The status line under the group shows the last delivery: when it worked, or why it did not
(an HTTP status, a timeout, a refused connection).
Behaviour worth knowing:
- **The URL is a secret.** Anyone holding a Slack or Discord webhook URL can post as it, and
anyone who knows an ntfy topic can read it. Codeman keeps it in its own file,
`~/.codeman/webhook.json` (readable by its owner only), never in the shared settings, and
never shows it again: once saved, the box is empty and the hint shows only the scheme and
host. Paste a new URL to replace it, or press **Remove URL** to delete it from the server
(which also turns the channel off).
- **On public ntfy.sh, pick a long random topic.** Topics there are not private; the name is
the only thing keeping strangers out.
- **Local targets work.** A self-hosted ntfy on your LAN or on the same machine is fine.
Link-local and cloud-metadata addresses are refused, both when you save and when the
message is sent, and redirects are not followed.
- **Repeats are folded.** The same event for the same session within three seconds is sent
once, so a flapping prompt cannot flood a channel.
- **Multi-user mode: admins only, and it sees everything.** Only an admin can see or change
the webhook, and it receives every user's session events (session names, tool names, error
text). Point it somewhere every user would be comfortable with.
## The Approvals Inbox
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
@@ -127,6 +173,10 @@ plain prose is not a dialog, so an agent that starts a monitor and then writes "
should I target?" is quiet along with the rest — check a watching session yourself if it has
been quiet longer than the work it is waiting for should take.
An agent waiting for your comments on an artifact it published never counts as watching.
Claude shows that as "1 Artifact comment monitor", but the agent hears nothing until you
comment, so the session alerts you like any other quiet session.
## The phone overview
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
@@ -148,7 +198,8 @@ It is the morning-after view for an overnight run. Enable its header button in
## Recommended setup for unattended runs
1. HTTPS access, ideally Tailscale. See [Remote Access](Remote-Access).
2. Push notifications subscribed, with Codeman installed to the home screen on iOS.
2. Push notifications subscribed, with Codeman installed to the home screen on iOS, or a
webhook to ntfy if no browser will ever be open.
3. Approvals Inbox on.
4. Auto-resume on usage limit on, for each session you leave running. See
[Keeping Agents Running](Keeping-Agents-Running).
@@ -158,7 +209,8 @@ from the lock screen.
## Gotchas
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one.
- **No push over plain HTTP.** It is a browser requirement, not a Codeman one. A webhook
has no such requirement, since the server sends it.
- **iOS needs the home screen install.** A Safari tab will never receive push.
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
- **Approvals need real signals.** They are built on hook events, which Claude emits and
+25 -9
View File
@@ -50,6 +50,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
| Key tester | n/a | A diagnostic that stores nothing. Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup, for when a chord such as Shift+Enter behaves differently on one device. Keys pressed there reach no session and trigger no shortcut. |
### Header & Panels
@@ -61,6 +62,9 @@ Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultrac
Ultracode Windows, Cron.
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
**Header Stats Style** picks how the system stats and plan usage are drawn: *Tiles*
(default; label over value with a bar underneath), *Compact* (one pill with sparklines plus
plan-usage rings) or *As before* (the bars and the `5H · 7D` chip). Desktop only, per device.
New header controls never appear on phones. Split is desktop-only regardless of this
setting — the button and the feature both stay off below a ~1180px viewport, where two
resizable panes plus their divider have nowhere to go.
@@ -78,7 +82,8 @@ every session or only the active tab.
| Interface Language | English or Simplified Chinese. Per device. |
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
| Tab Orientation | Keeps the header list but turns the strip vertical beside the terminal, resizable, with detailed rows by default. Desktop and tablet only. |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
| Tab Grouping | *By state* (default) splits the tabs into needs you, waiting, working and idle: rows in the header, sections in the rail and sidebar. *None* keeps one list in tab order. See [The Dashboard](The-Dashboard#tab-grouping-by-state). |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With grouping by state it orders the rows inside each section. |
| Tall Tabs | Taller tab strip. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
@@ -87,13 +92,22 @@ every session or only the active tab.
### Models
Claude model cards, the 1M context window switch, and the thinking effort segment. The cards
and the switch compose into one model choice, so there is no separate "which one wins"
question.
Claude model cards, the 1M context window switch, the thinking effort segment and the
advisor segment. The cards and the switch compose into one model choice, so there is no
separate "which one wins" question.
Model and effort are both **soft defaults**: the model is written into the case's
`.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort`
inside a session override them at any time.
Model, effort and advisor are all **soft defaults**: the model is written into the case's
`.claude/settings.local.json` and effort and advisor are passed at start, so `/model`,
`/effort` and `/advisor` inside a session override them at any time.
**Advisor** gives new Claude sessions Claude Code's
[advisor tool](https://code.claude.com/docs/en/advisor): a second, stronger model that Claude
consults before committing to an approach, when an error keeps coming back, and before it
calls a task done. A common pairing is a Sonnet main model with an Opus or Fable advisor,
which costs less than running the stronger model all the time. **Default** leaves it to
whatever you picked with `/advisor` yourself. The advisor needs the Anthropic API (not
Bedrock or Vertex), and an advisor that ranks below the session's model is simply not
attached.
**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
@@ -112,11 +126,13 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
| Nice priority / value | Runs agent processes at a lower CPU priority. |
| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. |
| Animated status effects | Cosmetic. |
| MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. |
### Notifications
Master toggle, browser notifications, push subscription, audio alerts, and the idle
threshold that decides when a quiet session counts as needing you. See
Master toggle, browser notifications, push subscription, audio alerts, the idle
threshold that decides when a quiet session counts as needing you, and the server-wide
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
[Notifications And Approvals](Notifications-And-Approvals).
### Voice
+47 -6
View File
@@ -27,7 +27,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| Layout | Behaviour |
| -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. Wraps to a second row on desktop, scrolls sideways on a phone. |
| **Header tab strip** | The default. On desktop it is one row per state (see [Tab grouping by state](#tab-grouping-by-state)); it scrolls sideways on a phone. |
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. Desktop and tablet only. |
@@ -35,6 +35,31 @@ It is the same list either way, just re-hosted: tab order, drag-to-reorder, the
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
per device, so a sidebar on your desktop does not force one onto your phone.
## Tab grouping by state
By default the tabs are grouped by what each session needs from you, most urgent on top:
| Group | Who is in it |
| ------------- | ----------------------------------------------------------------------------------- |
| **Needs you** | Red: a question or permission prompt is blocking the agent. A failed session too. |
| **Waiting** | Yellow: the agent finished its turn and is waiting for your next prompt. |
| **Working** | A turn is running. |
| **Idle** | Everything quiet, including ended sessions, agents that exited inside their pane, and web tabs. |
In the header each group is a row with its name and count on the left; a group with more
tabs than fit on one line continues under its own tabs. The vertical rail and the left
sidebar show the same groups as sections. Empty groups are not shown. These are the same
states the phone overview and the desktop home rail use.
Tabs move between groups on their own as their state changes. Inside a group they keep your
tab order (on a rail sorted *By activity*, the activity order), and the `Alt+1` to `Alt+9`
numbers never change. Dragging reorders tabs within a group. On a phone the strip stays a
single scrolling row: the tabs come in group order, without the headings. If you have named
tab groups in the vertical rail, those take precedence there.
Turn it off with **App Settings → Appearance → Tabs → Tab Grouping → None** to get one list
in tab order. Per device.
## Session tabs
One tab per session, in your order, and that order syncs across your devices.
@@ -102,7 +127,7 @@ The right side of the header. Almost all of these are off until you enable them
| ---------------------- | ------------------ | ------------------------------------------------------------------------------- |
| Connection dot | Always on | SSE connection health. Green is connected. |
| Font size `-` / `+` | Always on | `Ctrl +` / `Ctrl -` do the same. |
| CPU / MEM bars | On | Server resource use. |
| CPU / MEM | On | Server resource use. Drawn as tiles by default; see Header Stats Style below. |
| File Viewer | On | Toggles the file browser panel. |
| Settings gear | Always on | App Settings. |
| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. |
@@ -121,6 +146,19 @@ The right side of the header. Almost all of these are off until you enable them
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
| Admin panel | Multi-user only | User administration. |
### Header Stats Style
The connection readout, CPU, MEM and the plan usage windows can be drawn three ways
(**App Settings → Header & Panels → Header Stats Style**, per device, desktop only):
| Style | Look |
| -------------- | -------------------------------------------------------------------------------------- |
| **Tiles** | The default. One small tile each (`WS live`, `CPU 22%`, `MEM 14.4G`, `5H 28%`, `7D 35%`): label over value, a thin bar underneath, no icons. |
| **Compact** | One pill with `WS · CPU · MEM` and a tiny sparkline of the last few samples, then a pill with a ring per plan window. Hands the tabs back the most room. |
| **As before** | The bars and the `5H · 7D` chip, exactly as they were. |
Hiding System Stats or Plan Usage still hides them in every style.
New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide).
@@ -151,10 +189,13 @@ Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
and automatic output recovery stay within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
switching. Scrolling to the top of a Shell pane pulls the most recent 1 MiB of its tmux
history; press **Load full history** to pull the rest explicitly. Automatic output
recovery stays within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent
Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in
`~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal.
Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is
always local scrollback. Other CLIs scroll locally.
- **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not.
`Ctrl+Shift+C` always copies.
+5 -2
View File
@@ -164,8 +164,11 @@ Scrollback behaviour depends on the CLI, and Codeman adjusts what it strips per
Things to try:
- `Shift+Wheel` always scrolls the local buffer, whatever else is going on.
- On Claude sessions with a recent CLI, the wheel is forwarded into Claude's own transcript,
so it scrolls the conversation rather than the terminal buffer. That is intended.
- On Claude sessions running fullscreen (recent CLI with mouse tracking on), the wheel is
forwarded into Claude's own transcript, so it scrolls the conversation rather than the
terminal buffer. That is intended. Claude's default inline view scrolls locally; turn
fullscreen on with `CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in
`~/.claude/settings.json`.
- Scrolling to the very top pulls the full tmux scrollback again on demand.
### The wheel does nothing in a Codex session
+5 -3
View File
@@ -13,7 +13,8 @@ It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Syntax-aware preview. Long files are truncated in plain preview. |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| PDF and Office documents | Converted for preview when a converter is available. |
@@ -85,8 +86,9 @@ File paths in a session are links. That works in two places:
render as underlined monospace links.
Clicking one opens it in the preview: images and PDFs render, video and audio play with a
working scrub bar, documents convert, text and Markdown show inline. Log-shaped files open in
the tail viewer instead, which follows a file that is still being written.
working scrub bar, documents convert, text shows inline and Markdown renders. The exception is
a text or Markdown file inside the workspace clicked in the terminal: that opens in the tail
viewer instead, which follows a file that is still being written.
Paths **outside** the session's workspace work too, which matters because that is where most
of an agent's output lands: a screenshot in `/tmp`, a capture in its own scratchpad, a file in
+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')
+16 -3
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.32.1",
"version": "1.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.32.1",
"version": "1.34.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -32,6 +32,7 @@
"jpeg-js": "^0.4.4",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"smol-toml": "^1.9.0",
"undici": "^6.28.0",
"uuid": "^14.0.0",
"web-push": "^3.6.7",
@@ -10114,6 +10115,18 @@
"npm": ">= 3.0.0"
}
},
"node_modules/smol-toml": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
@@ -12372,7 +12385,7 @@
}
},
"packages/xterm-zerolag-input": {
"version": "0.3.1",
"version": "0.4.0",
"license": "MIT",
"devDependencies": {
"@xterm/headless": "^6.0.0",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.32.1",
"version": "1.34.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",
@@ -28,6 +28,7 @@
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
"fix:node-pty": "node scripts/fix-node-pty.mjs",
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
@@ -104,6 +105,7 @@
"jpeg-js": "^0.4.4",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"smol-toml": "^1.9.0",
"undici": "^6.28.0",
"uuid": "^14.0.0",
"web-push": "^3.6.7",
+28
View File
@@ -1,5 +1,33 @@
# xterm-zerolag-input
## 0.4.0
### Minor Changes
- 6aecc3b: ### Thanks
- @opticon454 for four PRs in one batch: webhook notifications (#523), MCP server sync (#521), the Shift+Enter keypress fix (#520) and the newline chord plus Key tester (#522). Every review item was answered in one round, and the merge-order map across all four made landing them together easy.
- @aakhter for the grouped vertical rail (#517) and its ARIA tree and full-row activation (#519), which give the owner tab-layout API its first frontend, and for the iOS IME composition preview (#499), carried through three careful review rounds including the overlay rework in the zerolag package.
- @irisitymichaelgrundberg for per-session Claude models on `POST /api/sessions` (#514) and Codex reasoning effort per session (#515), both kept registry-driven with no CLI id branching.
- @timkjr for keeping Pane B painting during a history pull and its "disconnected" marker last in every interleaving (#524), with an old-versus-new table measured in real Chrome.
**Webhook notifications (#523).** Settings → Notifications → Webhook posts the same events as Web Push (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any JSON URL, so a headless server can reach a phone with no browser open. Off by default. The URL is a bearer secret: it lives in its own 0600 file (`~/.codeman/webhook.json`), is never returned by the API, and the routes (`GET`/`PUT /api/webhook`, `POST /api/webhook/test`) are admin only in multi-user mode. Delivery goes through the web-tab egress guard (link-local and cloud-metadata targets refused), does not follow redirects, times out after 5 s, dedupes repeats, and neutralises `@everyone`/Slack control characters in agent-supplied text.
**MCP server sync (#521).** Opt-in (`mcpSyncEnabled`, synced, off by default; `GET`/`POST /api/mcp-sync` answer 403 until it is on). Settings → Agents & CLIs → MCP servers previews or copies each installed, enabled CLI's MCP servers into the others' own config files (Claude, Gemini, Codex, OpenCode, Antigravity). It only adds missing servers, never edits or removes one, skips servers you switched off, keeps a `.codeman-bak` of every file it changes, re-parses the result before writing, writes through symlinked dotfiles, leaves files that receive env values or headers readable by you only, and reports same-name conflicts instead of overwriting. CLIs with no known MCP config (Pi, Grok, OMP, DeepSeek) are listed as unsupported. Adds the `smol-toml` dependency to read Codex's `config.toml` safely.
**Claude advisor tool.** Claude Code's experimental advisor (a stronger model the session's main model consults at decision points) can now be set per session: an `advisorModel` field on `POST /api/sessions`, `POST /api/quick-start` and `POST /api/ralph-loop/start` (`fable`, `opus`, `sonnet`, or a full id in those families), and a synced App Settings default under Models → Advisor. It rides the launch's one `--settings` JSON rather than the `--advisor` flag, because the flag exits at launch on any pairing the CLI refuses and would leave a dead pane on every respawn. It is persisted, so respawns and both restore paths keep it, and `/advisor` still switches it in-session. Agents using the codeman skill can give their claude workers one with `CODEMAN_WORKER_ADVISOR=opus`.
**Per-session Claude model (#514) and Codex reasoning effort (#515).** `POST /api/sessions` takes an optional `model` that launches that one Claude session with `claude --model <id>` and writes nothing to disk (`modelOverride` still writes the case default). It is persisted, so both recovery paths relaunch on it. `codexConfig.reasoningEffort` starts a codex session at a chosen effort (`--config model_reasoning_effort=<level>`), and it survives respawn and resume.
**Grouped vertical rail (#517, #519).** When the owner has tab groups (`/api/tab-layout`), the vertical rail draws them as collapsible sections, with collapse remembered per device, the active row always visible, and lineage arcs anchored to a collapsed group's header. The grouped rail is an ARIA tree with one tab stop and the standard arrow-key model. With no groups, the rail is unchanged byte for byte. Editing groups from the browser comes in a follow-up.
**iOS IME composition preview (#499).** On iOS Safari, the text an IME is composing (Japanese, Chinese, Korean, and the predictive composition on English keyboards) is now drawn in the terminal before it commits, inside the local-echo overlay when local echo is on. Inert on every other platform. The `xterm-zerolag-input` package gains `setComposition()`.
**Key tester and newline chord (#522).** Settings → Terminal & Input has a Key tester that shows the keydown/keypress/keyup events the browser reports, to diagnose a device where a shortcut behaves differently. Keys pressed in it never trigger app shortcuts. Shift+Enter's newline chord is now CLI registry data (`capabilities.newline`, line feed by default); no stock CLI changes.
**Fixes.** Shift+Enter no longer submits the prompt after inserting the newline: the key handler swallowed only `keydown`, so xterm's `keypress` still sent a bare `\r` (#520). Claude sessions created at the same moment (`spawn_workers`, a multi-tab Run) no longer fall out of tmux onto the direct-PTY fallback: the statusLine exporter's temp file name collided within one millisecond (#531). Pane B of the split view keeps painting during a history pull, and its "disconnected" marker stays the last line however a close, a pull and a refresh interleave (#524).
**Fixes applied while landing.** Webhooks: the App Settings Save button now saves webhook edits too (a refused URL keeps the dialog open with a warning), Send test saves pending edits first, and a Remove URL button clears a saved URL. MCP sync: a config file that fails to parse is reported by line and column only, never by quoting its content, which can hold API keys; the sync follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME` and `GEMINI_CLI_HOME` from the server's environment and skips a target it cannot place instead of writing a file the CLI never reads; Preview before saving says to save first; and the MCP group is hidden from non-admins in multi-user mode. Grouped rail: a collapsed group's header shows the red or yellow ring of a hidden row that needs you; layout reads rebuild the rail only when something it draws changed, and failed reads back off (5, 10, 20, 40 s) instead of retrying every 5 s forever; a corrupted collapse preference resets instead of disabling collapse; Ctrl+Shift+{ / } only moves a tab within its own group; tapping a group header or row no longer dismisses the phone keyboard; keys pressed on a row's own buttons no longer move tree focus; and screen-reader positions stay correct after a re-sort. Sessions: `model` on `POST /api/sessions` refuses a value starting with a dash, and `model` or `advisorModel` together with `attachRemoteSession` is now a 400 instead of being ignored; non-Claude sessions no longer report or persist Claude's default model. Split view: a refresh queued behind a history pull no longer leaves a second, stale "disconnected" marker above its replay. iOS IME: a composition on an empty prompt now follows the prompt when output or a resize moves it, and the `xterm-zerolag-input` README documents `setComposition()`. The Shift+Enter and Key tester browser tests now drive the shipped handlers instead of copies.
## 0.3.1
### Patch Changes
+24 -9
View File
@@ -106,10 +106,10 @@ terminal.onData((data) => {
}
});
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink)
terminal.onWriteParsed(() => {
if (zerolag.hasPending) zerolag.rerender();
});
// 3. Re-render after terminal output (for full-screen TUI frameworks like Ink).
// Unconditional: rerender() is a no-op when there is nothing to draw, and
// hasPending would miss an overlay that shows only an IME composition.
terminal.onWriteParsed(() => zerolag.rerender());
```
That is the whole integration. Everything below is for tuning it.
@@ -186,7 +186,7 @@ If one terminal hosts several CLIs with different prompts, swap the strategy in
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
```
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
`setPrompt()` clears the cached prompt position and re-renders if the overlay has anything to draw, so a mode switch cannot leave the overlay pinned to the old column.
---
@@ -202,8 +202,9 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
|--------|---------|-------------|
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
| `appendText(text)` | `void` | Append multiple characters (paste). |
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
### Backspace handling
@@ -217,6 +218,19 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
### IME composition
While an input method (Japanese kana, Chinese pinyin, Korean) is still composing, the text is not committed yet, so it is not in `pendingText` either. `setComposition(text)` draws it as an underlined, `aria-hidden` tail right after the pending and flushed text, using the same wrapping and on-screen layout as the rest of the overlay.
```typescript
const textarea = terminal.textarea!;
textarea.addEventListener('compositionupdate', (e) => zerolag.setComposition(e.data));
textarea.addEventListener('compositionend', () => zerolag.setComposition(''));
// xterm then emits the committed text through onData: add it with addChar()/appendText() as usual.
```
The composition is visual only: it is never part of `pendingText`, `hasPending` or `state`, so it can never be sent. Control characters and line breaks are stripped from it. `clear()` and `removeChar()` drop it. Because `hasPending` excludes it, re-place the overlay after output or a resize with an unconditional `rerender()`, not one gated on `hasPending`.
### Flushed text
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
@@ -242,7 +256,7 @@ Finds text that exists after the prompt but was never typed through the overlay.
| Method | Description |
|--------|-------------|
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. A no-op when there is nothing to draw, so it needs no guard. |
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
### Prompt
@@ -258,7 +272,8 @@ Finds text that exists after the prompt but was never typed through the overlay.
| Property | Type | Description |
|----------|------|-------------|
| `pendingText` | `string` | Unacknowledged text (read-only) |
| `hasPending` | `boolean` | `true` if the overlay has any content |
| `hasPending` | `boolean` | `true` if there is pending or flushed text. Excludes the IME composition, so it can be `false` while the overlay still shows one |
| `composition` | `string` | The text set by `setComposition()`, `''` when none (read-only) |
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
### Options
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-zerolag-input",
"version": "0.3.1",
"version": "0.4.0",
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
"type": "module",
"main": "dist/index.cjs",
@@ -58,6 +58,7 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
const {
lines,
compositionStart,
startCol,
totalCols,
cellW,
@@ -90,12 +91,24 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
// `startCol` indents only the line that begins at the prompt marker, so it is
// dropped along with that line when the tail is all that fits.
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
// Code-point offset of each line in the whole text, so the composition
// styling survives the tail slice below.
const lineOffsets: number[] = [];
{
let offset = 0;
for (const line of lines) {
lineOffsets.push(offset);
offset += [...line].length;
}
}
let visibleLines = lines;
let firstVisible = 0;
let keepsPromptLine = true;
let topRow = promptRow;
if (rows && rows > 0) {
if (lines.length > rows) {
visibleLines = lines.slice(lines.length - rows);
firstVisible = lines.length - rows;
visibleLines = lines.slice(firstVisible);
keepsPromptLine = false;
topRow = 0;
} else if (promptRow + lines.length > rows) {
@@ -116,7 +129,21 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
const leftPx = indents ? startCol * cellW : 0;
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
const topPx = i * cellH;
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
const lineCompositionFrom =
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
const lineEl = makeLine(
visibleLines[i],
leftPx,
topPx,
widthPx,
cellH,
cellW,
charTop,
charHeight,
font,
terminal,
lineCompositionFrom
);
container.appendChild(lineEl);
}
@@ -144,7 +171,10 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
* Create a styled line `<div>` with per-character grid positioning.
*
* Each character gets its own `<span>` positioned by visual column offset.
* CJK wide characters occupy 2 cell widths.
* CJK wide characters occupy 2 cell widths. Characters at or after
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
* composition text: underlined, like xterm's own composition view, and marked
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
*/
function makeLine(
text: string,
@@ -156,7 +186,8 @@ function makeLine(
_charTop: number,
_charHeight: number,
font: FontStyle,
terminal?: XtermTerminal | null
terminal?: XtermTerminal | null,
compositionFrom?: number
): HTMLDivElement {
const el = document.createElement('div');
el.style.cssText = 'position:absolute;pointer-events:none';
@@ -172,6 +203,7 @@ function makeLine(
// CJK wide chars occupy 2 cells — position by visual column offset
let colOffset = 0;
let index = 0;
for (const ch of text) {
const cw = charCellWidth(terminal, ch);
const span = document.createElement('span');
@@ -189,9 +221,15 @@ function makeLine(
span.style.fontWeight = font.fontWeight;
span.style.color = font.color;
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
if (compositionFrom !== undefined && index >= compositionFrom) {
span.style.textDecoration = 'underline';
span.setAttribute('data-zerolag-composition', '');
span.setAttribute('aria-hidden', 'true');
}
span.textContent = ch;
el.appendChild(span);
colOffset += cw;
index++;
}
return el;
@@ -163,6 +163,12 @@ export interface CellDimensions {
/** Parameters for the overlay renderer. */
export interface RenderParams {
lines: string[];
/**
* Index (in code points, across all `lines`) where IME composition text
* begins. Characters from there on are drawn underlined and marked
* `data-zerolag-composition`. Omit when nothing is being composed.
*/
compositionStart?: number;
startCol: number;
totalCols: number;
cellW: number;
@@ -67,6 +67,8 @@ export class ZerolagInputAddon implements XtermAddon {
private _flushedOffset = 0;
private _flushedText = '';
private _bufferDetectDone = false;
// IME text still being composed: drawn after the pending text, never sent.
private _composition = '';
// Render cache
private _lastRenderKey = '';
@@ -130,7 +132,7 @@ export class ZerolagInputAddon implements XtermAddon {
clearTimeout(this._scrollTimer);
this._scrollTimer = null;
}
} else if (this._pendingText || this._flushedOffset > 0) {
} else if (this._hasContent()) {
if (this._scrollTimer) clearTimeout(this._scrollTimer);
this._scrollTimer = setTimeout(() => {
this._scrollTimer = null;
@@ -206,8 +208,14 @@ export class ZerolagInputAddon implements XtermAddon {
* - `'flushed'`: A character was removed from text already sent to the PTY.
* The consumer SHOULD send backspace to the PTY.
* - `false`: Nothing to remove. The consumer should NOT send backspace.
*
* Any IME composition is dropped in every case, and the overlay is repainted
* without it (hidden when nothing else is left).
*/
removeChar(): 'pending' | 'flushed' | false {
// A backspace that reaches the overlay means no composition is open.
const droppedComposition = this._composition.length > 0;
this._composition = '';
if (this._pendingText.length > 0) {
this._pendingText = this._pendingText.slice(0, -1);
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
@@ -243,6 +251,9 @@ export class ZerolagInputAddon implements XtermAddon {
return 'flushed';
}
// Nothing to remove, but a composition-only overlay is still on screen
// drawing the text dropped above.
if (droppedComposition) this._hide();
return false;
}
@@ -252,6 +263,7 @@ export class ZerolagInputAddon implements XtermAddon {
*/
clear(): void {
this._pendingText = '';
this._composition = '';
this._flushedOffset = 0;
this._flushedText = '';
this._bufferDetectDone = false;
@@ -297,7 +309,7 @@ export class ZerolagInputAddon implements XtermAddon {
clearFlushed(): void {
this._flushedOffset = 0;
this._flushedText = '';
if (this._pendingText) {
if (this._pendingText || this._composition) {
this._render();
} else {
this._hide();
@@ -312,7 +324,7 @@ export class ZerolagInputAddon implements XtermAddon {
* that move the prompt.
*/
rerender(): void {
if (this._pendingText || this._flushedOffset > 0) {
if (this._hasContent()) {
this._lastRenderKey = '';
this._render();
}
@@ -325,7 +337,7 @@ export class ZerolagInputAddon implements XtermAddon {
refreshFont(): void {
this._cacheFont();
this._lastRenderKey = '';
if (this._pendingText || this._flushedOffset > 0) this._render();
if (this._hasContent()) this._render();
}
// ─── Buffer detection ─────────────────────────────────────────────
@@ -391,7 +403,37 @@ export class ZerolagInputAddon implements XtermAddon {
this._options.prompt = finder;
this._lastPromptPos = null;
this._lastRenderKey = '';
if (this._pendingText || this._flushedOffset > 0) this._render();
if (this._hasContent()) this._render();
}
// ─── IME composition ──────────────────────────────────────────────
/**
* Show text an IME is still composing as an underlined tail after the
* pending text, wrapped and kept on screen like the rest of the overlay.
* Pass `''` to remove it.
*
* Visual only: the composition is never part of `pendingText`, `hasPending`
* or anything a consumer sends. When the IME commits, the consumer adds the
* committed text the usual way (`addChar`/`appendText`) and clears the
* composition. `clear()` and `removeChar()` drop it too.
*/
setComposition(text: string): void {
// One visual line of provisional text: control characters and line breaks
// would break the cell grid.
const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : '';
if (next === this._composition) return;
this._composition = next;
if (this._hasContent()) {
this._render();
} else {
this._hide();
}
}
/** Text an IME is still composing, drawn after `pendingText` (never sent). */
get composition(): string {
return this._composition;
}
// ─── Prompt utilities ─────────────────────────────────────────────
@@ -425,7 +467,13 @@ export class ZerolagInputAddon implements XtermAddon {
return this._pendingText;
}
/** Whether there is any overlay content (pending or flushed). */
/**
* Whether there is pending or flushed text. Excludes the IME composition,
* which is never sent, so an overlay showing only a composition reports
* `false` while still on screen. To re-place the overlay after output or a
* resize, call `rerender()` unconditionally: it is a no-op when there is
* nothing to draw.
*/
get hasPending(): boolean {
return this._pendingText.length > 0 || this._flushedOffset > 0;
}
@@ -443,6 +491,10 @@ export class ZerolagInputAddon implements XtermAddon {
// ─── Private methods ──────────────────────────────────────────────
private _hasContent(): boolean {
return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0;
}
private _getPromptOffset(): number {
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
return prompt.offset ?? 2;
@@ -505,7 +557,7 @@ export class ZerolagInputAddon implements XtermAddon {
private _render(): void {
if (!this._terminal || !this._overlay) return;
if (!this._pendingText && !(this._flushedOffset > 0)) {
if (!this._hasContent()) {
this._overlay.style.display = 'none';
return;
}
@@ -563,12 +615,16 @@ export class ZerolagInputAddon implements XtermAddon {
}
}
// The composition is a styled tail after everything the user has typed.
const compositionStart = [...displayText].length;
displayText += this._composition;
// Skip redundant re-renders — include text content to detect
// same-length changes (e.g., setFlushed with different text)
// `rows` is part of the key: the layout is clamped to the visible rows
// (see renderOverlay), so a keyboard opening — which changes rows without
// changing the text — must not be skipped as a redundant render.
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
this._lastRenderKey = renderKey;
@@ -608,6 +664,7 @@ export class ZerolagInputAddon implements XtermAddon {
renderOverlay(this._overlay, {
lines,
compositionStart: this._composition ? compositionStart : undefined,
startCol,
totalCols,
cellW,
@@ -0,0 +1,235 @@
import { describe, it, expect, afterEach } from 'vitest';
import { createMockTerminal } from './helpers.js';
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
// setComposition(): IME text still being composed, drawn as an underlined tail
// after the pending text. Visual only, never part of what a consumer sends.
const CELL_W = 10;
let cleanups: (() => void)[] = [];
afterEach(() => {
for (const fn of cleanups) fn();
cleanups = [];
});
function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) {
const mock = createMockTerminal({
buffer: { lines: opts.lines ?? ['$ '] },
cols: opts.cols,
rows: opts.rows,
cellWidth: CELL_W,
cellHeight: 20,
});
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
return { addon, mock, overlay };
}
/** Line divs of the overlay (the block cursor is a bare span, not a div). */
function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] {
return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
}
function lineText(line: HTMLDivElement): string {
return Array.from(line.children)
.map((s) => s.textContent)
.join('');
}
function compositionText(overlay: HTMLDivElement): string {
return Array.from(overlay.querySelectorAll('[data-zerolag-composition]'))
.map((s) => s.textContent)
.join('');
}
describe('setComposition', () => {
it('renders the composition after pendingText, underlined and aria-hidden', () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('xy');
const [line] = lineDivs(overlay);
expect(lineText(line)).toBe('abcxy');
const spans = Array.from(line.children) as HTMLSpanElement[];
for (const span of spans.slice(0, 3)) {
expect(span.hasAttribute('data-zerolag-composition')).toBe(false);
expect(span.style.textDecoration).toBe('');
}
for (const span of spans.slice(3)) {
expect(span.hasAttribute('data-zerolag-composition')).toBe(true);
expect(span.getAttribute('aria-hidden')).toBe('true');
expect(span.style.textDecoration).toBe('underline');
}
// Grid positions continue straight on from the pending text.
expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
expect(overlay.style.display).toBe('');
});
it('places a wide composition by cell width after wide pending text', () => {
const { addon, overlay } = setup();
addon.appendText('今日は');
addon.setComposition('天気');
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
});
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
const { addon } = setup();
addon.appendText('abc');
addon.setFlushed(2, 'zz');
addon.setComposition('xy');
expect(addon.pendingText).toBe('abc');
expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' });
expect(addon.composition).toBe('xy');
expect(addon.state.pendingText).toBe('abc');
expect(addon.state.flushedText).toBe('zz');
});
it('shows on an empty prompt without making anything pending', () => {
const { addon, overlay } = setup();
addon.setComposition('かな');
expect(addon.pendingText).toBe('');
expect(addon.hasPending).toBe(false);
expect(addon.state.visible).toBe(true);
expect(compositionText(overlay)).toBe('かな');
});
it('wraps with the pending text: the tail continues onto the next line', () => {
// 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line.
const { addon, overlay } = setup({ cols: 12 });
addon.appendText('abcdefgh');
addon.setComposition('WXYZ');
const lines = lineDivs(overlay);
expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']);
expect(compositionText(overlay)).toBe('WXYZ');
const second = Array.from(lines[1].children) as HTMLSpanElement[];
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
expect(second[0].style.left).toBe('0px');
});
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
// 2 visible rows, 3 lines of text: the first line is dropped.
const { addon, overlay } = setup({ cols: 6, rows: 2 });
addon.appendText('abcdefghij');
addon.setComposition('XYZ');
const lines = lineDivs(overlay);
expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']);
expect(compositionText(overlay)).toBe('XYZ');
const first = Array.from(lines[0].children);
expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false);
});
it("setComposition('') removes the tail and keeps the pending text", () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('xy');
addon.setComposition('');
expect(lineText(lineDivs(overlay)[0])).toBe('abc');
expect(compositionText(overlay)).toBe('');
expect(addon.pendingText).toBe('abc');
});
it("setComposition('') on an otherwise empty overlay hides it", () => {
const { addon, overlay } = setup();
addon.setComposition('xy');
addon.setComposition('');
expect(overlay.style.display).toBe('none');
expect(overlay.innerHTML).toBe('');
});
it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('xy');
addon.clear();
expect(addon.composition).toBe('');
expect(overlay.style.display).toBe('none');
addon.addChar('q');
expect(lineText(lineDivs(overlay)[0])).toBe('q');
});
it('removeChar() drops the composition and removes a pending char, not a composed one', () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('xy');
expect(addon.removeChar()).toBe('pending');
expect(addon.pendingText).toBe('ab');
expect(addon.composition).toBe('');
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
});
it('removeChar() with nothing to remove still takes a composition-only overlay off screen', () => {
const { addon, overlay } = setup();
addon.setComposition('ka');
expect(compositionText(overlay)).toBe('ka');
expect(addon.removeChar()).toBe(false);
expect(addon.composition).toBe('');
expect(compositionText(overlay)).toBe('');
expect(overlay.style.display).toBe('none');
expect(addon.state.visible).toBe(false);
});
it('removeChar() repaints flushed text without the dropped composition', () => {
const { addon, overlay } = setup();
addon.setFlushed(3, 'abc');
addon.setComposition('xy');
expect(addon.removeChar()).toBe('flushed');
expect(compositionText(overlay)).toBe('');
expect(lineText(lineDivs(overlay)[0])).toBe('ab');
});
it('text appended while composing lands before the tail', () => {
const { addon, overlay } = setup();
addon.appendText('ab');
addon.setComposition('xy');
addon.addChar('c');
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
expect(compositionText(overlay)).toBe('xy');
});
it('rerender() and refreshFont() keep the composition', () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('xy');
addon.rerender();
expect(compositionText(overlay)).toBe('xy');
addon.refreshFont();
expect(compositionText(overlay)).toBe('xy');
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
});
it('re-renders when only the composition changes', () => {
const { addon, overlay } = setup();
addon.appendText('abc');
addon.setComposition('x');
addon.setComposition('xy');
expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
});
it('strips control characters and line breaks from the composition', () => {
const { addon, overlay } = setup();
addon.setComposition('a\nb\u0007c
');
expect(addon.composition).toBe('abc');
expect(compositionText(overlay)).toBe('abc');
});
it('draws the block cursor after the composition', () => {
const { addon, overlay } = setup();
addon.appendText('ab');
addon.setComposition('xy');
const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
// prompt col 0 + offset 2 + 4 cells
expect(cursor.style.left).toBe(6 * CELL_W + 'px');
});
});
+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.34.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+26 -8
View File
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -196,6 +196,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -207,12 +212,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -372,10 +384,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.30.1
CODEMAN_PREAMBLE=1.33.4
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -426,7 +438,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -493,6 +505,12 @@ Four things this block leans on, each one link away, no detour needed to run it:
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
stronger model it consults before committing to an approach, on a recurring error and
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
below the worker's own model is never attached (on an Opus worker only `opus` and
`fable` do anything).
- Deleting the sessions does **not** remove the case directories. They are marked as
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
+16 -4
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -118,6 +118,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -129,12 +134,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -294,4 +306,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.30.1
CODEMAN_PREAMBLE=1.33.4
@@ -340,11 +340,18 @@ 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.
A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
model id): Claude Code's advisor tool, a stronger model the worker consults before
committing to an approach, on a recurring error and before declaring the task done. It is
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
`CODEMAN_WORKER_ADVISOR` is set.
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
@@ -384,17 +391,18 @@ every claude create path installs them, so a linked case and a raw path both get
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
`envOverrides`). Three differences that break copied code:
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`).
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
`wait?until=exit` answers `exit` immediately. Follow it with
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
(`session-routes.ts:648`).
(`sessionCapacityMessage()` in `route-helpers.ts`).
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
@@ -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
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
@@ -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
+4
View File
@@ -83,9 +83,11 @@ appendFileSync(
// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite');
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
@@ -111,8 +113,10 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
'mobile-ime-preview.js',
'terminal-keycode229-recovery.js',
'sanitize-html.js',
'tab-layout-browser.js',
'app.js',
'tab-rail-resize.js',
'terminal-ui.js',
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env node
/**
* Browser-test exclusion check.
*
* `npm run test:ci` must never try to drive a real browser: CI runners (and any
* clean checkout) have no chromium, so such a file dies with
* `browserType.launch: Executable doesn't exist` and takes the whole suite with
* it. `config/vitest.ci.config.ts` therefore excludes every browser-driven test
* via `BROWSER_TEST_GLOBS` in `config/test-suites.ts`. That list is maintained
* BY HAND, and a new browser test simply does not appear in it unless someone
* remembers. The omission is invisible on a developer machine that has run
* `npx playwright install`, where the test passes, and only shows up on a clean
* runner.
*
* Two deliberate design choices:
*
* 1. **Detection is by CONTENT, not filename.** Matching `*.browser.test.ts`
* would miss the browser tests that predate that convention
* (`inline-rename`, `opencode-resize`, `webgl-fallback`,
* `terminal-copy-shortcut`, `codex-predictive-echo`). What actually makes a
* file dangerous is importing a browser driver, so that is what is tested.
* ⚠️ Only a DIRECT import is seen: a test that reaches playwright through a
* helper module (e.g. `test/mobile/helpers/browser.ts`) is not detected, so
* such a test still has to be added to `BROWSER_TEST_GLOBS` by hand.
*
* 2. **The exclusion side is answered by vitest itself**, via
* `vitest list --filesOnly`, rather than by re-implementing glob matching
* against the config's `exclude` array. Patterns there include `test/mobile/**`
* and `perf-*`; a hand-rolled matcher that disagreed with vitest by even one
* edge case would report a gap that does not exist, or miss one that does.
* Asking the real resolver cannot drift from the real behaviour.
*
* The pure pieces are exported for test/check-browser-test-excludes.test.ts; the
* check itself only runs when this file is executed directly.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname, relative, sep, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CI_CONFIG = join('config', 'vitest.ci.config.ts');
const SUITES_FILE = join('config', 'test-suites.ts');
/** Importing any one of these means the test needs a real browser binary. */
const BROWSER_DRIVER =
/\bfrom\s+['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]|\b(?:require|import)\(\s*['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]\s*\)/;
/** @param {string} source */
export function importsBrowserDriver(source) {
return BROWSER_DRIVER.test(source);
}
/** @param {string} dir @returns {string[]} */
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(path));
else if (entry.isFile() && entry.name.endsWith('.test.ts')) out.push(path);
}
return out;
}
/**
* Every `*.test.ts` under `<root>/test`, as sorted repo-relative POSIX paths (the form
* `vitest list` prints).
*
* @param {string} root
* @returns {string[]}
*/
export function findTestFiles(root) {
return walk(join(root, 'test'))
.map((file) => relative(root, file).split(sep).join('/'))
.sort();
}
/**
* The subset of {@link findTestFiles} that imports a browser driver.
*
* @param {string} root
* @returns {string[]}
*/
export function findBrowserTests(root) {
return findTestFiles(root).filter((file) => importsBrowserDriver(readFileSync(join(root, file), 'utf8')));
}
/**
* Parse `vitest list --filesOnly` output into a set of repo-relative paths. Stray
* blank or decorative lines are ignored rather than assuming the format is pristine.
*
* @param {string} output
* @returns {Set<string>}
*/
export function parseVitestFileList(output) {
return new Set(
output
.split('\n')
.map((line) => line.trim())
.filter((line) => line.endsWith('.test.ts'))
.map((line) => line.replace(/^\.\//, ''))
);
}
/**
* Whether the `vitest list` paths and the walked tree name at least one file in common.
* False means the two sides are not speaking the same path format (absolute paths, backslashes
* or a new prefix after a vitest upgrade), and then {@link findLeaks} would find nothing
* against a perfectly non-empty listing.
*
* @param {Set<string>} ciFiles
* @param {string[]} testFiles
*/
export function listingMatchesTree(ciFiles, testFiles) {
return testFiles.some((file) => ciFiles.has(file));
}
/**
* @param {string[]} browserTests
* @param {Set<string>} ciFiles
* @returns {string[]} browser-driven files that the CI config would still collect
*/
export function findLeaks(browserTests, ciFiles) {
return browserTests.filter((file) => ciFiles.has(file));
}
function main() {
const testFiles = findTestFiles(ROOT);
const browserTests = findBrowserTests(ROOT);
let collected;
try {
collected = execFileSync('npx', ['vitest', 'list', '--config', CI_CONFIG, '--filesOnly'], {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (err) {
console.error('✗ could not enumerate the CI test set via `vitest list`.');
console.error(err.stderr ? err.stderr.toString() : String(err));
process.exit(1);
}
const ciFiles = parseVitestFileList(collected);
if (ciFiles.size === 0) {
// An empty list would make every browser test look excluded: fail rather than pass vacuously.
console.error('✗ `vitest list` reported no test files; refusing to pass on an empty CI set.');
process.exit(1);
}
// Same vacuous pass, one step removed: a listing whose paths never match the tree. This guard,
// not `vitest list --json`, is the answer to format drift: the JSON form prints absolute paths
// that would need canonicalizing against ROOT (symlinked checkouts), and its shape can drift too.
if (!listingMatchesTree(ciFiles, testFiles)) {
const sample = [...ciFiles].slice(0, 3).join(', ');
console.error(
`✗ none of the ${ciFiles.size} paths \`vitest list\` reported (e.g. ${sample}) is one of the ${testFiles.length} test/**/*.test.ts files; its output format has probably changed.`
);
process.exit(1);
}
const leaked = findLeaks(browserTests, ciFiles);
if (leaked.length > 0) {
console.error(`✗ ${leaked.length} browser-driven test file(s) are NOT excluded from ${CI_CONFIG}:\n`);
for (const file of leaked) console.error(` ${file}`);
console.error(`
These import a browser driver, so on a runner with no chromium they fail with
"browserType.launch: Executable doesn't exist" and take the suite down. Add each
to BROWSER_TEST_GLOBS in ${SUITES_FILE} (${CI_CONFIG} derives its excludes from
it, and \`npm run test:browser\` its includes).
They may well pass on this machine; that is the trap. To reproduce a clean
runner locally:
PLAYWRIGHT_BROWSERS_PATH=\$(mktemp -d) PUPPETEER_CACHE_DIR=\$(mktemp -d) npm run test:ci`);
process.exit(1);
}
console.log(
`✓ all ${browserTests.length} browser-driven test files are excluded from the CI suite (${ciFiles.size} files collected)`
);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
+253
View File
@@ -0,0 +1,253 @@
/**
* @fileoverview Git hook bodies + install policy, shared by scripts/postinstall.js and
* pinned by test/git-hooks.test.ts.
*
* Why a pre-push hook: the static CI job (lockfile, typecheck, lint, format, frontend
* syntax, ...) fails often on things a contributor could have caught locally in seconds,
* and finding out after a push costs a full CI round-trip plus a fix-up commit. Running
* the same checks before the push surfaces those failures in ~10-40s instead (12s on a fast
* workstation, ~35s measured elsewhere; typecheck, format:check and lint dominate).
*
* Why pre-PUSH and not pre-commit: a commit is cheap and local, a push is what CI and
* reviewers pick up. And why the STATIC tier only: the unit/integration suite takes
* minutes, which nobody tolerates per push, so a hook that ran it would be bypassed
* within a day. The checks below mirror the static CI job.
*
* ⚠️ The checks read the WORKING TREE, not the commits being pushed. So the hook skips
* (with a one-line notice) whenever the two can differ: when HEAD is not the commit being
* pushed, and when `git status` shows uncommitted or untracked changes in a path a check
* reads ({@link PRE_PUSH_WATCHED_PATHS}). In a checkout shared by several agent sessions
* the second case is usually another session's WIP, which must not block this push.
*
* ⚠️ This installer is deliberately MARKER-OWNED, unlike the older pre-commit installer in
* postinstall.js which overwrites whatever it finds. A developer's own pre-push hook must
* survive `npm install`.
*/
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
/**
* Ownership marker. ⚠️ Never bump the version suffix: ownership is matched on this exact
* string, so a `v2` would read every installed `v1` hook as foreign and never refresh it.
* A changed body still reaches installed hooks, because the refresh compares the whole file.
*/
export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1';
/**
* Checks that make up the fast tier, cheapest first so failures surface sooner. Each entry
* is the argument list for `npm run`, and each is a step of the static job in
* .github/workflows/ci.yml (test/git-hooks.test.ts pins that every script exists).
*/
export const PRE_PUSH_CHECKS = [
['check:lockfile'],
['generate:cli-catalog', '--', '--check'],
['check:browser-excludes'],
['check:frontend-syntax'],
['format:check'],
['lint'],
['typecheck'],
];
/**
* Paths whose uncommitted state would leak into a check, so a dirty one makes the hook skip.
* Derived from what each check reads: src/ (format:check, lint, typecheck,
* check:frontend-syntax), config/ (eslint + vitest configs, test-suites.ts, the CLI
* catalogue), scripts/ (every check is a script there, and typecheck's second pass compiles
* one), test/ (check:browser-excludes scans it and runs `vitest list` over it),
* package.json + package-lock.json (check:lockfile), install.sh (generate:cli-catalog
* --check diffs its generated block), tsconfig.json (typecheck, and
* config/tsconfig.scripts.json extends it) and .prettierignore + .editorconfig
* (format:check; the Prettier CLI honours .editorconfig by default).
*/
export const PRE_PUSH_WATCHED_PATHS = [
'src',
'config',
'scripts',
'test',
'package.json',
'package-lock.json',
'install.sh',
'tsconfig.json',
'.prettierignore',
'.editorconfig',
];
/**
* Render the pre-push hook script.
*
* POSIX sh, not bash: this ships to whatever shell the contributor's git uses.
*/
export function renderPrePushHook() {
const runs = PRE_PUSH_CHECKS.map((args) => `run_check ${args.join(' ')}`).join('\n');
const watched = PRE_PUSH_WATCHED_PATHS.join(' ');
return `#!/bin/sh
${PRE_PUSH_MARKER}
# Installed by scripts/postinstall.js. Edit scripts/git-hooks.mjs, not this file:
# it is regenerated on npm install. Delete the marker line above to take ownership
# and the installer will leave your version alone.
#
# Skip once: CODEMAN_SKIP_PREPUSH=1 git push
# Skip always: remove this file.
[ "$CODEMAN_SKIP_PREPUSH" = "1" ] && exit 0
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
cd "$repo_root" || exit 0
# Nothing to check without dependencies (fresh clone, or a worktree that never ran
# npm install). Warn rather than blocking the push on a setup detail.
if [ ! -d node_modules ]; then
echo "pre-push: node_modules missing, skipping checks (run 'npm install' to enable them)."
exit 0
fi
# GUI git clients and IDEs often run hooks with a minimal PATH that lacks an nvm or
# Homebrew Node. Every check would then fail with "npm: not found", so skip instead.
command -v npm >/dev/null 2>&1 || { echo "pre-push: npm not on PATH, skipping checks."; exit 0; }
# git feeds us "<localref> <localsha> <remoteref> <remotesha>" per ref. A deletion has an
# all-zero local sha and no tree worth checking; if every ref is a deletion, skip.
# The checks below read the working tree, so they only say something about a pushed commit
# that IS the checked-out HEAD (tags are peeled to their commit first).
head=$(git rev-parse -q --verify HEAD 2>/dev/null)
has_content=0
not_head=''
while read -r localref localsha _remoteref _remotesha; do
[ -z "$localsha" ] && continue
case "$localsha" in
0000000000000000000000000000000000000000) ;;
*)
has_content=1
commit=$(git rev-parse -q --verify "$localsha^{commit}" 2>/dev/null)
[ -n "$head" ] && [ "$commit" = "$head" ] || not_head="$localref"
;;
esac
done
[ "$has_content" = "0" ] && exit 0
if [ -n "$not_head" ]; then
echo "pre-push: skipping static checks: $not_head is not the checked-out HEAD, and the checks read the working tree."
exit 0
fi
# Uncommitted or untracked changes in a path a check reads would be judged instead of the
# pushed commit. In a checkout shared by several sessions that is usually someone else's WIP.
if [ -n "$(git --no-optional-locks status --porcelain -- ${watched} 2>/dev/null)" ]; then
echo "pre-push: skipping static checks: uncommitted changes under ${watched} would be checked instead of the pushed commit."
exit 0
fi
log=$(mktemp "\${TMPDIR:-/tmp}/codeman-prepush.XXXXXX") || exit 0
trap 'rm -f "$log"' EXIT
failed=''
run_check() {
if ! npm run --silent "$@" >"$log" 2>&1; then
echo ""
echo "pre-push: FAILED npm run $*"
tail -n 25 "$log"
failed="$failed $1"
fi
}
echo "pre-push: running static checks (~10-40s)..."
${runs}
if [ -n "$failed" ]; then
echo ""
echo "pre-push: blocked by:$failed"
echo "Fix, or push anyway with: CODEMAN_SKIP_PREPUSH=1 git push"
exit 1
fi
echo "pre-push: static checks passed."
exit 0
`;
}
/**
* Decide what to do with an existing hook file.
*
* @param {{ existing: string | null | undefined, next: string }} args
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
*/
export function planHookInstall({ existing, next }) {
if (existing === null || existing === undefined || existing.trim() === '') return 'write';
if (!existing.includes(PRE_PUSH_MARKER)) return 'skip-foreign';
return existing === next ? 'up-to-date' : 'write';
}
/** @param {string} cwd @param {string[]} args */
function git(cwd, args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
}
/**
* realpath() that tolerates a missing leaf: a fresh `.git` may have no `hooks/` yet, so
* canonicalize the parent and re-append the name. Throws if the parent is missing too.
*
* @param {string} path
*/
function canonicalPath(path) {
return existsSync(path) ? realpathSync(path) : join(realpathSync(dirname(path)), basename(path));
}
/**
* Resolve the hooks directory for the checkout rooted at `repoRoot`, or null when there
* is nothing to install into.
*
* Asks git (`--git-path hooks`) rather than assuming `<root>/.git/hooks`: in a worktree
* `.git` is a FILE pointing at the parent repo, so the hooks live under
* `--git-common-dir`.
*
* ⚠️ Returns a directory ONLY when it is this repository's own `<git-common-dir>/hooks`.
* `--git-path hooks` also reports `core.hooksPath`, and that setting is often GLOBAL (a
* shared hooks directory used by every repo on the machine); installing there would
* overwrite the user's own hooks and run Codeman's checks on unrelated repos. A
* `core.hooksPath` that points back at the repo's own hooks dir still resolves, because
* the comparison is on canonical paths rather than on whether the setting exists.
*
* Also returns null unless `repoRoot` is itself the top of a work tree. Without that guard,
* a copy of this package sitting inside SOMEONE ELSE's repository (e.g. under their
* node_modules) would resolve to their hooks directory and install Codeman's hook there.
*
* @param {string} repoRoot
* @returns {string | null}
*/
export function resolveGitHooksDir(repoRoot) {
try {
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
// Both are printed relative to the cwd (repoRoot) unless already absolute.
const hooks = git(repoRoot, ['rev-parse', '--git-path', 'hooks']);
const common = git(repoRoot, ['rev-parse', '--git-common-dir']);
if (!hooks || !common) return null;
const own = join(realpathSync(resolve(repoRoot, common)), 'hooks');
return canonicalPath(resolve(repoRoot, hooks)) === own ? own : null;
} catch {
return null;
}
}
/**
* Install (or refresh) the managed pre-push hook in `hooksDir`, honouring
* {@link planHookInstall}: a hook without the marker is never touched.
*
* @param {string} hooksDir
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
*/
export function installPrePushHook(hooksDir) {
const path = join(hooksDir, 'pre-push');
const next = renderPrePushHook();
const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
const action = planHookInstall({ existing, next });
if (action === 'write') {
mkdirSync(hooksDir, { recursive: true });
writeFileSync(path, next, { mode: 0o755 });
chmodSync(path, 0o755); // `mode` only applies when the file is created
}
return action;
}
+26 -1
View File
@@ -64,6 +64,15 @@ export const GIT_HOST_CLI_BUILD_ARGS = [
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
];
/**
* Environment variable → Dockerfile ARG for the image's system Git identity.
* ⚠️ Mirrored by `GIT_IDENTITY_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
*/
export const GIT_IDENTITY_BUILD_ARGS = [
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
];
/**
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
@@ -82,9 +91,25 @@ export function gitHostCliBuildArgPairs(env) {
return pairs;
}
/** The `--build-arg` pairs for Git identity, requiring either both values or neither. */
export function gitIdentityBuildArgPairs(env) {
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? '']);
const configured = pairs.filter(([, value]) => value !== '');
if (configured.length === 0) return [];
if (configured.length !== pairs.length) {
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
throw new Error(`${names} must both be set when configuring Git identity`);
}
return pairs;
}
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
export function agentImageBuildArgPairs(catalog, env = process.env) {
return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')], ...gitHostCliBuildArgPairs(env)];
return [
['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')],
...gitHostCliBuildArgPairs(env),
...gitIdentityBuildArgPairs(env),
];
}
/** Read the committed catalogue. IO. */
+16 -4
View File
@@ -356,14 +356,17 @@ if (!isGlobalInstall) {
}
// ----------------------------------------------------------------------------
// 5. Install git pre-commit hook (format check)
// 5. Install git hooks (pre-commit format check, pre-push static checks)
// ----------------------------------------------------------------------------
if (!isGlobalInstall) {
try {
const { writeFileSync, mkdirSync } = await import('fs');
const gitHooksDir = join(import.meta.dirname, '..', '.git', 'hooks');
if (existsSync(join(import.meta.dirname, '..', '.git'))) {
const { resolveGitHooksDir, installPrePushHook } = await import('./git-hooks.mjs');
// Resolved through git, not `../.git/hooks`: in a worktree `.git` is a file.
// null when this directory is not the top of a git checkout.
const gitHooksDir = resolveGitHooksDir(join(import.meta.dirname, '..'));
if (gitHooksDir) {
mkdirSync(gitHooksDir, { recursive: true });
const hook = `#!/bin/bash
# Auto-installed by postinstall — prevents CI format failures
@@ -379,9 +382,18 @@ fi
const hookPath = join(gitHooksDir, 'pre-commit');
writeFileSync(hookPath, hook, { mode: 0o755 });
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
// Unlike the pre-commit hook above, this one is marker-owned: a pre-push
// hook the developer wrote themselves is left alone.
const action = installPrePushHook(gitHooksDir);
if (action === 'write') {
console.log(colors.green('✓ Git pre-push hook installed') + colors.dim(' (static CI checks, ~10-40s)'));
} else if (action === 'skip-foreign') {
console.log(colors.dim(' Existing pre-push hook left untouched (not Codeman-managed)'));
}
}
} catch {
// Non-critical — git hook is a convenience
// Non-critical — git hooks are a convenience
}
}
+20 -1
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."
+26 -8
View File
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.33.4$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -196,6 +196,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -207,12 +212,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -372,10 +384,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.30.1
CODEMAN_PREAMBLE=1.33.4
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -426,7 +438,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -493,6 +505,12 @@ Four things this block leans on, each one link away, no detour needed to run it:
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
- For long or high-stakes worker tasks, `CODEMAN_WORKER_ADVISOR=opus spawn_workers "${N[@]}"`
(`fable`, `opus` or `sonnet`) gives each claude worker Claude Code's advisor tool: a
stronger model it consults before committing to an approach, on a recurring error and
before declaring the task done. Advisor calls bill extra tokens, and an advisor ranked
below the worker's own model is never attached (on an Opus worker only `opus` and
`fable` do anything).
- Deleting the sessions does **not** remove the case directories. They are marked as
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
+16 -4
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.33.4 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -118,6 +118,11 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
@@ -129,12 +134,19 @@ spawn_worker() {
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -294,4 +306,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.30.1
CODEMAN_PREAMBLE=1.33.4
+12 -4
View File
@@ -340,11 +340,18 @@ 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.
A claude worker also takes `"advisorModel":"opus"` (`fable`, `opus`, `sonnet` or a full
model id): Claude Code's advisor tool, a stronger model the worker consults before
committing to an approach, on a recurring error and before declaring the task done. It is
a soft default the worker can change with `/advisor`. Remote and docker cases refuse it
(400), as they refuse `effort`. `spawn_worker` and `spawn_workers` send it for you when
`CODEMAN_WORKER_ADVISOR` is set.
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
@@ -384,17 +391,18 @@ every claude create path installs them, so a linked case and a raw path both get
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`,
`envOverrides`). Three differences that break copied code:
`advisorModel`, `envOverrides`, and for claude a per-session `model` passed as `--model`). Three
differences that break copied code:
- The id is at **`.data.session.id`**, not quick-start's `.data.sessionId`
(`session-routes.ts:878` returns `{ session: lightState }`).
(the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`).
- **It spawns no PTY.** The session exists with `pid:null` and nothing running, so
`wait?until=exit` answers `exit` immediately. Follow it with
`POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or
`POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker.
- Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's
`SESSION_BUSY` (409), from the same global-50 / per-user-25 caps
(`session-routes.ts:648`).
(`sessionCapacityMessage()` in `route-helpers.ts`).
⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit
circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn
+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
+1 -1
View File
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.33.4 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
+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;
+38
View File
@@ -15,6 +15,7 @@
import { z } from 'zod';
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { McpConfigFormat } from './types.js';
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
@@ -27,6 +28,14 @@ const envName = z
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
.max(64);
/** A relative file path with no traversal or odd characters (MCP sync writes to it). */
const mcpRelativePath = z
.string()
.min(1)
.max(100)
.regex(/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/)
.refine((v) => !v.split('/').includes('..'), 'must not contain ..');
/**
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
@@ -338,6 +347,15 @@ const capabilitiesSchema = z
// Bounded hard: this is how far up the screen a config file may push the search,
// and every row it adds is one more row the agent itself may be able to write.
watchingLines: z.number().int().min(1).max(8).optional(),
// Same guard again: tested against a pane row every time a session settles.
awaitingLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
@@ -368,6 +386,26 @@ const capabilitiesSchema = z
privilegedEnvKeys: z.array(envName).max(8),
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
newline: z.enum(['line-feed', 'esc-enter']).optional(),
mcpConfig: z
.object({
// Home-relative, no traversal: sync writes to this path.
path: mcpRelativePath,
// Every value must be a known McpConfigFormat (types.ts); mcp-sync.ts's dialect table is
// keyed by the same type, so an adapter-less format fails to compile there.
format: z.enum([
'claude-json',
'gemini-json',
'codex-toml',
'opencode-json',
'antigravity-json',
] as const satisfies readonly McpConfigFormat[]),
// The env var the CLI reads to move the file, and the path under it (same no-traversal
// rule: sync writes there too). Resolved from the server env at call time, never here.
relocation: z.object({ envVar: envName, path: mcpRelativePath }).strict().optional(),
})
.strict()
.optional(),
customModelInjection: z.discriminatedUnion('kind', [
z
.object({
+56 -2
View File
@@ -11,6 +11,7 @@
*/
import type { CliEntry } from './types.js';
import { CODEX_REASONING_EFFORTS } from '../../types/session.js';
const HOME_DIRS = {
local: '~/.local/bin',
@@ -90,7 +91,7 @@ function agentDefaults(): Pick<
// `accent` still has no reader, so nothing rendered changes because of it.
const CLAUDE: CliEntry = {
id: 'claude' as CliEntry['id'],
label: 'Claude',
label: 'Claude Code',
shortBadge: 'CC',
accent: '#3b82f6',
enabled: true,
@@ -237,7 +238,24 @@ const CLAUDE: CliEntry = {
// carry a count. A footer that ever drew the chip as its only item would report no
// watching rather than open that door. See `watchingLabel()` in
// `session-activity.ts`.
watchingLine: String.raw`·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`,
// ⚠️ An Artifact comment monitor is the one chip that waits on the user. The agent
// has published a page and hears nothing until somebody comments on it, so the
// lookahead refuses the whole row while that chip is on it, whatever else is
// running beside it. The `^` is what makes the lookahead judge the row once:
// without it the engine retries from each later position, and a start past the
// chip reports the shell beside it. The lookahead keys on "Artifact" alone, so a
// footer cut off mid-chip (`· 1 Artifact…`, `· 1 Artifact comm…`) is still refused;
// no other chip on this row says "Artifact". Counting the chip as watching kept the
// idle alert quiet for a session that was waiting for a human.
watchingLine: String.raw`^(?!.*Artifact).*?·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?))`,
// When a turn ends while background agents or an ultracode workflow are still
// running, Claude swaps its `✻ Brewed for 1m 18s` closing row for
// `✻ Waiting for 2 background agents and 1 dynamic workflow to finish` and resumes
// by itself when they report back. Read from the 2.1.283 bundle (the turn-duration
// renderer) and a live pane on 2026-09-28. The row is a snapshot taken at turn end
// and never redrawn, which is why only the newest row above the composer counts.
// Anchored on column 0: Claude's own rows start there, the agent's prose never does.
awaitingLine: String.raw`^✻ Waiting for \d+ (?:background agents?|dynamic workflows?)\b`,
},
requiresMux: false,
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
@@ -246,6 +264,8 @@ const CLAUDE: CliEntry = {
transcript: 'claude-jsonl',
altScreen: 'strip-full',
echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } },
// Declared-for-later: the live rule (`_shouldForwardWheelToApp`, terminal-ui.js) is this version
// AND the server-published `cliMouseTracking` flag (#498), so wiring this field up needs both.
wheelForward: { mode: 'version-gated', minVersion: '2.1.187' },
keyboardAccessory: 'agent',
privilegedCommandGate: false,
@@ -287,6 +307,12 @@ const CLAUDE: CliEntry = {
'CLAUDE_CONFIG_DIR',
],
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
mcpConfig: {
path: '.claude.json',
format: 'claude-json',
relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
},
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
// llama.cpp server. Claude reads these at process start only, so switching requires a
// respawn, never a live hot-swap.
@@ -467,6 +493,12 @@ const OPENCODE: CliEntry = {
...agentDefaults(),
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: {
path: '.config/opencode/opencode.json',
format: 'opencode-json',
relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
},
// Verified by hand against a real llama.cpp server. Reuses the SAME env var opencode's
// own `env.configContentVar` already declares — the builder in custom-model-injection.ts
// must merge into whatever opencode config Codeman would otherwise send, not clobber it.
@@ -513,6 +545,7 @@ const CODEX: CliEntry = {
bypassApprovals: { type: 'bool' },
animations: { type: 'bool' },
model: { type: 'token', pattern: 'model' },
reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] },
resumeId: { type: 'token', pattern: 'id' },
},
variants: [
@@ -524,6 +557,14 @@ const CODEX: CliEntry = {
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
// One literal per level: an argv token cannot splice a value into a literal, and
// `model_reasoning_effort=<level>` is a single `--config` value. The enum above is
// what admits a level, so an unknown one emits nothing.
...CODEX_REASONING_EFFORTS.map((level) => ({
flag: '--config',
value: `model_reasoning_effort=${level}`,
when: { param: 'reasoningEffort', is: level },
})),
{ lit: 'resume', when: { param: 'resumeId', state: 'set' } },
{ valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
],
@@ -601,6 +642,11 @@ const CODEX: CliEntry = {
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
mcpConfig: {
path: '.codex/config.toml',
format: 'codex-toml',
relocation: { envVar: 'CODEX_HOME', path: 'config.toml' },
},
// Verified by hand against a real llama.cpp server. Written to an isolated CODEX_HOME
// so the user's real ~/.codex/config.toml is never touched.
customModelInjection: {
@@ -702,6 +748,12 @@ const GEMINI: CliEntry = {
// MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who
// sends no geminiConfig at all would still get yolo for free.
privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }],
// gemini-cli's `homedir()` returns `GEMINI_CLI_HOME` when set (packages/core/src/utils/paths.ts).
mcpConfig: {
path: '.gemini/settings.json',
format: 'gemini-json',
relocation: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
},
// Web-researched, unverified — needs a restart to pick up (CLI reads these at process
// start). Confirm the exact model-override env var name against the installed
// gemini-cli version before shipping.
@@ -781,6 +833,8 @@ const ANTIGRAVITY: CliEntry = {
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
// SENT config needs the flag forced off — nothing is materialized.
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
// No relocation var: `agy` 1.1.12 resolves `~/.gemini/config` from $HOME only.
mcpConfig: { path: '.gemini/config/mcp_config.json', format: 'antigravity-json' },
// No known CLI/env/config mechanism — Antigravity's own docs describe a GUI-only
// custom-endpoint setting and explicitly say it "cannot currently" become the core
// reasoning model. Toolbar entry stays disabled for this mode.
+47 -4
View File
@@ -90,6 +90,12 @@ export interface CliVariant {
args: ArgSpec[];
}
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
export type NewlineSequence = 'line-feed' | 'esc-enter';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
export interface CliLaunch {
params: Record<string, ParamSpec>;
/**
@@ -362,6 +368,19 @@ export interface CliCapabilities {
* alert. See `watchingLabel()` in `session-activity.ts`.
*/
watchingLines?: number;
/**
* Source of a regex matching the row this CLI closes a turn with when it ended that
* turn to WAIT for workers it started and will resume on its own once they finish,
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`. A pane showing it counts
* as working, not idle: nothing is being asked of the user, and the next turn starts
* without them.
*
* Unlike `workingLine` this is never searched across the pane. The CLI prints the row
* once and never updates it, so the copy from an earlier turn is still on screen after
* the workers are done. Only the newest transcript row directly above the composer is
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
*/
awaitingLine?: string;
};
/**
* How many columns this CLI indents its transcript body by, so a copy taken from its
@@ -498,6 +517,28 @@ export interface CliCapabilities {
gates: Record<string, { minVersion: string; failClosed: boolean }>;
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
maxFrameBytes?: number;
/**
* The bytes the web UI types into this CLI's pane for Shift+Enter (the `send-key` route).
* `line-feed` (`0x0a`, also what Ctrl+Enter sends) is what Claude Code's Ink input and most TUIs
* read as "insert a newline"; `esc-enter` (`ESC` `CR`, the same chord as Option/Alt+Enter and
* the mobile ⌥Enter key) is for a TUI that ignores a bare line feed. Absent = `line-feed`.
* Data, not a branch on the CLI id, so supporting another CLI's quirk is one line here.
*/
newline?: NewlineSequence;
/**
* Where this CLI keeps its user-level MCP server list, for MCP sync (`src/mcp-sync.ts`).
* `path` is relative to the home directory. `format` names the file dialect the sync
* adapter reads and writes. Absent = no known/verified MCP config file, so the CLI is
* skipped by sync rather than guessed at.
*
* `relocation` names the env var the CLI itself reads to move that file (codex's
* `CODEX_HOME`, claude's `CLAUDE_CONFIG_DIR`, opencode's `XDG_CONFIG_HOME`). When the SERVER
* process env (what the CLIs Codeman spawns inherit) sets it to an absolute directory, the
* file is `<that dir>/<relocation.path>` instead; set to anything else, the target is
* reported `skipped` rather than written somewhere the CLI never reads. Absent = the file
* only follows `$HOME`.
*/
mcpConfig?: { path: string; format: McpConfigFormat; relocation?: { envVar: string; path: string } };
/**
* How this CLI is pointed at a user-supplied custom OpenAI-compatible
* endpoint (local, e.g. llama.cpp, or cloud, e.g. Azure AI Foundry) — the
@@ -670,12 +711,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'] },
+8
View File
@@ -99,3 +99,11 @@ export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000;
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
/**
* Gap between a paste-mode cron prompt's text and its Enter (ms). The two must be
* separate writes: Claude Code takes a raw `<text>\r` burst of about a hundred
* characters as a paste and turns its `\r` into a newline. A separate `\r` 80 ms
* after the text was measured to submit; this leaves room for a longer prompt.
*/
export const CRON_PASTE_ENTER_DELAY_MS = 300;
+38 -9
View File
@@ -20,7 +20,7 @@ import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
import { CRON_PASTE_ENTER_DELAY_MS, CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
import {
DEFAULT_BLOCKED_TREES,
isBlockedAttachmentPath,
@@ -100,6 +100,41 @@ const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TR
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
const HAS_NEWLINE = /[\r\n]/;
/** The three session calls prompt delivery needs, so it can be tested without a PTY. */
type CronPromptTarget = Pick<Session, 'write' | 'writeViaMux' | 'verifySubmitted'>;
/**
* Send a cron job's (single-line) prompt into its session and press Enter.
*
* `typed` goes through the mux: the text is typed, Enter is its own key, and the
* session re-presses it while the prompt is still on the composer.
*
* `paste` writes the text straight into the PTY, and must send its Enter as a
* SEPARATE write. It used to send `<text>\r` in one piece, and Claude Code (measured
* on 2.1.283) takes a burst of about a hundred characters as a paste, so the `\r`
* landed as a newline and the prompt sat unsent while the run reported
* `prompt_sent`. The Enter goes down the same PTY as the text, so it cannot overtake
* it, and the same composer check then covers a CLI that was not taking Enter yet.
*
* @returns false when the session had no PTY or mux to write to
*/
export async function deliverCronPrompt(
target: CronPromptTarget,
prompt: string,
inputMode: CronJob['inputMode'],
wait: (ms: number) => Promise<void> = delay
): Promise<boolean> {
if (inputMode !== 'paste') {
return target.writeViaMux(prompt.endsWith('\r') ? prompt : `${prompt}\r`);
}
const text = prompt.replace(/[\r\n]+$/, '');
if (!target.write(text)) return false;
await wait(CRON_PASTE_ENTER_DELAY_MS);
if (!target.write('\r')) return false;
target.verifySubmitted(text);
return true;
}
/** Order-insensitive equality for the weekly-days arrays. */
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
const x = [...(a ?? [])].sort((p, q) => p - q);
@@ -623,15 +658,9 @@ export class CronService {
const s = this.deps.sessions.get(sessionId);
if (!s) return;
try {
const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`;
let delivered = true;
if (job.inputMode === 'paste') {
s.write(payload);
} else {
delivered = await s.writeViaMux(payload);
}
const delivered = await deliverCronPrompt(s, prompt, job.inputMode);
if (!delivered) {
this.failRun(job, run, 'Failed to send prompt: mux write failed');
this.failRun(job, run, 'Failed to send prompt: the session could not be written to');
return;
}
run.status = 'prompt_sent';
+31 -2
View File
@@ -615,6 +615,15 @@ export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray<readonly [string, string]> =
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
];
/**
* Environment variable → Dockerfile ARG for the image's system Git identity.
* ⚠️ Mirrors `GIT_IDENTITY_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
*/
export const GIT_IDENTITY_BUILD_ARGS: ReadonlyArray<readonly [string, string]> = [
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
];
/**
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
@@ -633,9 +642,29 @@ export function gitHostCliBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string,
return pairs;
}
/**
* The `--build-arg` pairs for a configured Git identity. An absent pair leaves
* Git unconfigured, preserving existing deployments; a partial pair is refused.
* ⚠️ Mirrors `gitIdentityBuildArgPairs()` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
*/
export function gitIdentityBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> {
const pairs = GIT_IDENTITY_BUILD_ARGS.map(([envName, argName]) => [argName, env[envName] ?? ''] as [string, string]);
const configured = pairs.filter(([, value]) => value !== '');
if (configured.length === 0) return [];
if (configured.length !== pairs.length) {
const names = GIT_IDENTITY_BUILD_ARGS.map(([envName]) => envName).join(' and ');
throw new Error(`${names} must both be set when configuring Git identity`);
}
return pairs;
}
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> {
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')], ...gitHostCliBuildArgPairs(env)];
return [
['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')],
...gitHostCliBuildArgPairs(env),
...gitIdentityBuildArgPairs(env),
];
}
// ========== Credential mount resolution (IO) ==========
@@ -1204,7 +1233,7 @@ function buildAgentImage(
try {
buildArgPairs = agentImageBuildArgPairs();
} catch (err) {
// A malformed CODEMAN_AGENT_IMAGE_INSTALL_* value: report it like any other build failure.
// A malformed CODEMAN_AGENT_IMAGE_* value: report it like any other build failure.
return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) });
}
const argv = dockerEngineArgv(docker);
+5 -1
View File
@@ -1031,7 +1031,11 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
// windows in which Claude Code could run an empty or non-executable file.
// rename() swaps the complete, already-executable file in atomically.
const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`;
// ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created
// concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared
// name let the first rename consume the others' temp file, and their ENOENT
// dropped those sessions from tmux to the direct-PTY fallback.
const tmpPath = `${scriptPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmpPath, desired);
await chmod(tmpPath, 0o755);
await rename(tmpPath, scriptPath);
+679
View File
@@ -0,0 +1,679 @@
/**
* @fileoverview MCP server sync between the enabled agent CLIs.
*
* Each CLI keeps its own user-level MCP list in its own dialect (`CliEntry.capabilities.mcpConfig`
* names the file and the dialect). This module reads every participating CLI's list into one
* neutral shape, and adds any server a CLI is missing from the others. The whole feature is
* opt-in (`mcpSyncEnabled`, default OFF; the route enforces it) because it writes OTHER tools'
* own user config.
*
* Deliberately conservative:
* - ADDITIVE only. A server already present under a name (in ANY shape, even one this module
* does not understand) is never rewritten and nothing is ever removed. Same name with a
* different definition is reported as a conflict and left alone.
* - A server the user has switched off in its own CLI (codex `enabled = false`, opencode
* `enabled: false`, antigravity `disabled: true`) is not propagated: copying it would
* switch it on in every other CLI.
* - A file that does not parse (e.g. opencode JSONC with comments, a TOML file with a
* duplicate table) is never written, and a write is only made after the NEW text has been
* parsed again and every added server comes back as intended.
* - Only the MCP table is touched; every other key in the file is preserved. Files are
* re-read immediately before the write and replaced via tmp+rename next to the REAL target
* (a symlinked dotfile stays a symlink), with the old file kept as `<file>.codeman-bak`
* (overwritten by each sync).
* - Copied servers can carry secrets in `env`/`headers`: a file that receives any is left
* readable by its owner only.
* - Servers a dialect cannot express (SSE for codex) are skipped and reported.
* - Only one apply runs at a time.
* - A CLI whose file was moved by its own env var (`mcpConfig.relocation`: `CODEX_HOME`,
* `CLAUDE_CONFIG_DIR`, ...) is followed there, as the SERVER env sets it; a relative value
* cannot be located safely, so that target is reported `skipped` and never written.
*
* The result types (src/types/mcp-sync.ts) never carry env values or headers: those commonly
* hold secrets and the result is returned over HTTP. For the same reason a parse failure is
* reported by position only (`describeMcpSyncError`): parsers quote the offending source.
*
* @module mcp-sync
*/
import { promises as fs } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import { parse as parseToml, TomlError } from 'smol-toml';
import type { McpConfigFormat } from './config/cli-registry/types.js';
import type { McpSyncResult, McpSyncTargetResult } from './types/mcp-sync.js';
export type McpFormat = McpConfigFormat;
export interface McpServer {
transport: 'stdio' | 'http' | 'sse';
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
/** Switched off in the CLI that defines it. Never propagated. */
disabled?: boolean;
}
export type McpServerMap = Record<string, McpServer>;
export interface McpSyncTarget {
id: string;
label: string;
/** Home-relative default location of the config file. */
path: string;
format: McpFormat;
/** The env var the CLI reads to move the file, and the path under it (`mcpConfig.relocation`). */
relocation?: { envVar: string; path: string };
/** The CLI's binary resolves on this machine. A CLI that is not installed and has no config file is left alone. */
installed: boolean;
}
/** A second apply was requested while one was running. */
export class McpSyncBusyError extends Error {
constructor() {
super('An MCP sync is already running');
this.name = 'McpSyncBusyError';
}
}
/**
* An error whose message this module wrote itself. It names keys Codeman chose and server names
* (which the result reports anyway), never a value from the file, so it may be shown as is.
*/
class McpConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'McpConfigError';
}
}
/**
* What a target's `error` may say. A parser's own message can quote the file: smol-toml's
* `TomlError` carries a code frame of the offending line and the one before it, and V8's JSON
* "Unexpected token" errors quote about ten characters of source. These files hold env values
* and headers and the result goes over HTTP, so a parse failure is reported by position only,
* an errno failure by Node's own message (code, syscall and path: no file content), and anything
* else by a fixed category.
*/
function describeMcpSyncError(err: unknown): string {
if (err instanceof McpConfigError) return err.message;
if (err instanceof TomlError) return `not valid TOML (line ${err.line}, column ${err.column})`;
if (err instanceof SyntaxError) {
const lc = /\(line (\d+) column (\d+)\)/.exec(err.message);
if (lc) return `not valid JSON (line ${lc[1]}, column ${lc[2]})`;
const pos = /at position (\d+)/.exec(err.message);
return pos ? `not valid JSON (position ${pos[1]})` : 'not valid JSON';
}
const code = (err as NodeJS.ErrnoException | null)?.code;
if (err instanceof Error && typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) return err.message;
return 'unexpected error';
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
/** Names that would reach Object.prototype through a plain-object table (`out[name] = ...`). */
const UNSAFE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
/** A table keyed by untrusted names: no prototype, so `toString`/`hasOwnProperty` are ordinary keys. */
function dict<T>(): Record<string, T> {
return Object.create(null) as Record<string, T>;
}
/** Own, safe keys of an untrusted table. */
function safeKeys(table: Record<string, unknown>): string[] {
return Object.keys(table).filter((k) => !UNSAFE_NAMES.has(k));
}
function strMap(v: unknown): Record<string, string> | undefined {
if (!isRecord(v)) return undefined;
const out = dict<string>();
for (const k of safeKeys(v)) if (typeof v[k] === 'string') out[k] = v[k] as string;
return Object.keys(out).length ? out : undefined;
}
function strArr(v: unknown): string[] | undefined {
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : undefined;
}
/** Drop undefined/empty fields so equal servers compare equal. */
function clean(s: McpServer): McpServer {
const out: McpServer = { transport: s.transport };
if (s.command) out.command = s.command;
if (s.args?.length) out.args = s.args;
if (s.env && Object.keys(s.env).length) out.env = s.env;
if (s.cwd) out.cwd = s.cwd;
if (s.url) out.url = s.url;
if (s.headers && Object.keys(s.headers).length) out.headers = s.headers;
if (s.disabled) out.disabled = true;
return out;
}
const sortedEntries = (m: Record<string, string> | undefined): [string, string][] =>
Object.entries(m ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
/** Identity for conflict detection: what the server runs/connects to, not how it is spelled. */
function fingerprint(s: McpServer): string {
const t = s.transport === 'stdio' ? 'stdio' : 'url';
return JSON.stringify([t, s.command ?? null, s.args ?? [], s.url ?? null]);
}
/** Fingerprint plus the secrets-bearing maps: what must survive a write unchanged. */
function fullIdentity(s: McpServer): string {
return JSON.stringify([fingerprint(s), sortedEntries(s.env), sortedEntries(s.headers)]);
}
const carriesSecrets = (m: McpServerMap): boolean => Object.values(m).some((s) => s.env || s.headers);
// ---------------------------------------------------------------------------
// JSON dialects
// ---------------------------------------------------------------------------
function fromClaude(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const type = raw.type;
if ((type === 'http' || type === 'sse') && typeof raw.url === 'string') {
return clean({ transport: type, url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({ transport: 'stdio', command: raw.command, args: strArr(raw.args), env: strMap(raw.env) });
}
return null;
}
function toClaude(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') return { type: 'stdio', command: s.command, args: s.args ?? [], env: s.env ?? {} };
return { type: s.transport, url: s.url, ...(s.headers ? { headers: s.headers } : {}) };
}
function fromGemini(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
// `httpUrl` is the legacy streamable-http key; `url` + `type` is what `gemini mcp add` writes
// today, and a bare `url` with no type is the legacy SSE form.
if (typeof raw.httpUrl === 'string')
return clean({ transport: 'http', url: raw.httpUrl, headers: strMap(raw.headers) });
if (typeof raw.url === 'string') {
return clean({ transport: raw.type === 'http' ? 'http' : 'sse', url: raw.url, headers: strMap(raw.headers) });
}
if (typeof raw.command === 'string') {
return clean({
transport: 'stdio',
command: raw.command,
args: strArr(raw.args),
env: strMap(raw.env),
cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
});
}
return null;
}
function toGemini(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') {
return {
command: s.command,
args: s.args ?? [],
...(s.env ? { env: s.env } : {}),
...(s.cwd ? { cwd: s.cwd } : {}),
};
}
return { url: s.url, type: s.transport, ...(s.headers ? { headers: s.headers } : {}) };
}
/** Antigravity (`agy mcp add`): stdio or http only; http servers use `serverUrl`. */
function fromAntigravity(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.disabled === true;
if (typeof raw.serverUrl === 'string')
return clean({ transport: 'http', url: raw.serverUrl, headers: strMap(raw.headers), disabled });
if (typeof raw.command === 'string') {
return clean({
transport: 'stdio',
command: raw.command,
args: strArr(raw.args),
env: strMap(raw.env),
disabled,
});
}
return null;
}
function toAntigravity(s: McpServer): Record<string, unknown> | null {
if (s.transport === 'sse') return null;
if (s.transport === 'stdio') {
return { command: s.command, args: s.args ?? [], ...(s.env ? { env: s.env } : {}), disabled: false };
}
return { serverUrl: s.url, ...(s.headers ? { headers: s.headers } : {}), disabled: false };
}
function fromOpencode(raw: unknown): McpServer | null {
if (!isRecord(raw)) return null;
const disabled = raw.enabled === false;
if (raw.type === 'remote' && typeof raw.url === 'string') {
return clean({ transport: 'http', url: raw.url, headers: strMap(raw.headers), disabled });
}
if (raw.type === 'local') {
const cmd = strArr(raw.command);
if (!cmd?.length) return null;
return clean({
transport: 'stdio',
command: cmd[0],
args: cmd.slice(1),
env: strMap(raw.environment),
disabled,
});
}
return null;
}
function toOpencode(s: McpServer): Record<string, unknown> {
if (s.transport === 'stdio') {
return {
type: 'local',
command: [s.command, ...(s.args ?? [])],
...(s.env ? { environment: s.env } : {}),
enabled: true,
};
}
return { type: 'remote', url: s.url, ...(s.headers ? { headers: s.headers } : {}), enabled: true };
}
interface JsonDialect {
/** Key holding the server table. */
key: string;
from(raw: unknown): McpServer | null;
to(s: McpServer): Record<string, unknown> | null;
/** Top-level keys to seed when creating the file from nothing. */
seed?: Record<string, unknown>;
}
const JSON_DIALECTS: Record<Exclude<McpFormat, 'codex-toml'>, JsonDialect> = {
'claude-json': { key: 'mcpServers', from: fromClaude, to: toClaude },
'gemini-json': { key: 'mcpServers', from: fromGemini, to: toGemini },
'antigravity-json': { key: 'mcpServers', from: fromAntigravity, to: toAntigravity },
'opencode-json': {
key: 'mcp',
from: fromOpencode,
to: toOpencode,
seed: { $schema: 'https://opencode.ai/config.json' },
},
};
// ---------------------------------------------------------------------------
// Codex TOML (the `[mcp_servers.*]` tables only)
// ---------------------------------------------------------------------------
function fromCodex(t: Record<string, unknown>): McpServer | null {
const disabled = t.enabled === false;
if (typeof t.url === 'string') {
return clean({ transport: 'http', url: t.url, headers: strMap(t.http_headers), disabled });
}
if (typeof t.command === 'string') {
return clean({ transport: 'stdio', command: t.command, args: strArr(t.args), env: strMap(t.env), disabled });
}
return null;
}
const tomlStr = (v: string): string => JSON.stringify(v);
const tomlKey = (k: string): string => (/^[A-Za-z0-9_-]+$/.test(k) ? k : tomlStr(k));
function toCodexToml(name: string, s: McpServer): string {
const head = `[mcp_servers.${tomlKey(name)}]`;
const lines = [head];
if (s.transport === 'stdio') {
lines.push(`command = ${tomlStr(s.command ?? '')}`);
lines.push(`args = [${(s.args ?? []).map(tomlStr).join(', ')}]`);
if (s.env) {
lines.push('', `[mcp_servers.${tomlKey(name)}.env]`);
for (const [k, v] of Object.entries(s.env)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
}
} else {
lines.push(`url = ${tomlStr(s.url ?? '')}`);
if (s.headers) {
lines.push('', `[mcp_servers.${tomlKey(name)}.http_headers]`);
for (const [k, v] of Object.entries(s.headers)) lines.push(`${tomlKey(k)} = ${tomlStr(v)}`);
}
}
return lines.join('\n') + '\n';
}
// ---------------------------------------------------------------------------
// Dialect entry points
// ---------------------------------------------------------------------------
export interface ParsedConfig {
/** Servers this module understands. */
servers: McpServerMap;
/** Every name defined under the MCP table, in any shape: these are never appended over. */
names: Set<string>;
}
/** The MCP table of a config file's text (null = file absent). Throws if it cannot be read safely. */
function mcpTable(format: McpFormat, text: string | null): Record<string, unknown> {
if (text === null || !text.trim()) return dict<unknown>();
if (format === 'codex-toml') {
const doc = parseToml(text);
const table = doc.mcp_servers;
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new McpConfigError('"mcp_servers" is not a table');
return table;
}
const dialect = JSON_DIALECTS[format];
const doc: unknown = JSON.parse(text);
if (!isRecord(doc)) throw new McpConfigError('top level is not a JSON object');
const table = doc[dialect.key];
if (table === undefined) return dict<unknown>();
if (!isRecord(table)) throw new McpConfigError(`"${dialect.key}" is not an object`);
return table;
}
/** Parse a config file's text (null = file absent). Throws if it cannot be read safely. */
export function parseConfig(format: McpFormat, text: string | null): ParsedConfig {
const table = mcpTable(format, text);
const servers = dict<McpServer>();
const names = new Set<string>();
for (const name of safeKeys(table)) {
names.add(name);
const raw = table[name];
const s =
format === 'codex-toml'
? isRecord(raw)
? fromCodex(raw)
: null
: JSON_DIALECTS[format as Exclude<McpFormat, 'codex-toml'>].from(raw);
if (s) servers[name] = s;
}
return { servers, names };
}
/** The servers of a config file's text. */
export function parseServers(format: McpFormat, text: string | null): McpServerMap {
return parseConfig(format, text).servers;
}
/** Whether this dialect can express the server. */
function canExpress(format: McpFormat, s: McpServer): boolean {
if (format === 'codex-toml' || format === 'antigravity-json') return s.transport !== 'sse';
return true;
}
/**
* Add servers to a config file's text and return the new text. A name already defined under the
* MCP table (in any shape) is skipped; the new text is parsed again and every added server must
* come back as intended, otherwise this throws and nothing should be written.
*/
export function addServers(format: McpFormat, text: string | null, add: McpServerMap): string {
const before = parseConfig(format, text);
const todo = dict<McpServer>();
for (const n of safeKeys(add)) if (!before.names.has(n) && canExpress(format, add[n])) todo[n] = add[n];
const names = Object.keys(todo);
if (names.length === 0) return text ?? '';
let out: string;
if (format === 'codex-toml') {
const base = text ?? '';
const eol = base.includes('\r\n') ? '\r\n' : '\n';
const sep =
base.length === 0
? ''
: base.endsWith('\n\n') || base.endsWith('\r\n\r\n')
? ''
: base.endsWith('\n')
? eol
: eol + eol;
const blocks = names.map((n) => toCodexToml(n, todo[n]).replace(/\n/g, eol));
out = base + sep + blocks.join(eol);
} else {
const dialect = JSON_DIALECTS[format];
const doc: Record<string, unknown> =
text && text.trim() ? (JSON.parse(text) as Record<string, unknown>) : { ...dialect.seed };
const existing = doc[dialect.key];
const table: Record<string, unknown> = isRecord(existing) ? existing : {};
for (const n of names) {
const entry = dialect.to(todo[n]);
if (entry) table[n] = entry;
}
doc[dialect.key] = table;
out = JSON.stringify(doc, null, 2) + '\n';
}
// Re-read what we are about to write.
const after = parseConfig(format, out);
for (const n of before.names) {
if (!after.names.has(n)) throw new McpConfigError(`refusing to write: "${n}" would be lost`);
}
for (const n of names) {
const got = after.servers[n];
if (!got || fullIdentity(got) !== fullIdentity(todo[n])) {
throw new McpConfigError(`refusing to write: "${n}" does not read back as written`);
}
}
return out;
}
// ---------------------------------------------------------------------------
// Orchestration
// ---------------------------------------------------------------------------
async function readText(file: string): Promise<string | null> {
try {
return await fs.readFile(file, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
/**
* Write `text` over `file`, keeping the old content as `<file>.codeman-bak`. Follows a symlink
* to the real file so a symlinked dotfile stays a symlink. When `secret` is set the result is
* readable by its owner only.
*/
async function writeAtomic(file: string, text: string, secret: boolean): Promise<void> {
let target = file;
try {
if ((await fs.lstat(file)).isSymbolicLink()) target = await fs.realpath(file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// ENOENT from realpath on a dangling link, or lstat on a missing file: tell them apart.
try {
await fs.lstat(file);
throw new McpConfigError('config path is a dangling symlink');
} catch (inner) {
if ((inner as NodeJS.ErrnoException).code !== 'ENOENT') throw inner;
}
}
let mode = 0o600;
try {
mode = (await fs.stat(target)).mode & 0o777;
await fs.copyFile(target, `${target}.codeman-bak`);
await fs.chmod(`${target}.codeman-bak`, 0o600);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
if (secret) mode &= ~0o077;
await fs.mkdir(dirname(target), { recursive: true });
const tmp = `${target}.codeman-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
try {
await fs.writeFile(tmp, text, { mode });
// writeFile's mode is masked by the umask; the mode we computed is the one we mean.
await fs.chmod(tmp, mode);
await fs.rename(tmp, target);
} catch (err) {
await fs.unlink(tmp).catch(() => undefined);
throw err;
}
}
export interface McpSyncOptions {
/** false = report what would change without writing. */
apply: boolean;
home?: string;
/**
* Where relocation env vars (`McpSyncTarget.relocation`) are read from: the env the CLIs
* Codeman spawns would inherit. Defaults to `process.env`, except when `home` is overridden
* (tests, throwaway homes): then it defaults to none, so a relocation var in the caller's own
* env can never aim a write outside that home.
*/
env?: Record<string, string | undefined>;
}
/**
* The config file a target means, honouring its relocation env var. `skip` is set when the var
* holds something that cannot be located safely (a relative path resolves against the CLI's
* working directory, which differs per session), so the target is neither read nor written.
*/
function resolveFile(
t: McpSyncTarget,
home: string,
env: Record<string, string | undefined>
): { file: string; skip?: string } {
const rel = t.relocation;
const dir = rel ? env[rel.envVar] : undefined;
// Every CLI declared today treats an empty value as unset (`||` / a non-empty filter).
if (!rel || dir === undefined || dir === '') return { file: join(home, t.path) };
if (!isAbsolute(dir)) {
return {
file: `$${rel.envVar}/${rel.path}`,
skip: `${rel.envVar} is set to a relative path, so the file ${t.label} reads cannot be located safely`,
};
}
return { file: join(dir, rel.path) };
}
let applying = false;
/**
* Sync across `targets` (already filtered to enabled CLIs with an `mcpConfig`, in priority
* order: when two CLIs define a name differently, the first one's definition is the one copied).
* Throws `McpSyncBusyError` if another apply is running.
*/
export async function syncMcpServers(
targets: McpSyncTarget[],
opts: McpSyncOptions,
unsupported: string[] = []
): Promise<McpSyncResult> {
if (opts.apply) {
if (applying) throw new McpSyncBusyError();
applying = true;
}
try {
return await run(targets, opts, unsupported);
} finally {
if (opts.apply) applying = false;
}
}
async function run(targets: McpSyncTarget[], opts: McpSyncOptions, unsupported: string[]): Promise<McpSyncResult> {
const home = opts.home ?? homedir();
const env = opts.env ?? (opts.home === undefined ? process.env : {});
const seen = new Set<string>();
const live = targets
.map((t) => ({ t, ...resolveFile(t, home, env) }))
.filter(({ file }) => (seen.has(file) ? false : (seen.add(file), true)));
const state = live.map(({ t, file, skip }) => {
const res: McpSyncTargetResult = {
id: t.id,
label: t.label,
file,
status: skip ? 'skipped' : 'ok',
...(skip ? { error: skip } : {}),
servers: [],
added: [],
skipped: [],
};
return { t, file, res, servers: dict<McpServer>(), names: new Set<string>() };
});
for (const s of state) {
if (s.res.status !== 'ok') continue;
try {
if (!s.t.installed && !(await exists(s.file))) {
s.res.status = 'absent';
continue;
}
const parsed = parseConfig(s.t.format, await readText(s.file));
s.servers = parsed.servers;
s.names = parsed.names;
s.res.servers = [...parsed.names];
} catch (err) {
s.res.status = 'unreadable';
s.res.error = describeMcpSyncError(err);
}
}
// Union, first enabled definition wins; a later, different definition of the same name is a conflict.
const union = dict<McpServer>();
const conflicts = new Set<string>();
const switchedOff = new Set<string>();
for (const s of state) {
if (s.res.status !== 'ok') continue;
for (const name of Object.keys(s.servers)) {
const def = s.servers[name];
if (def.disabled) {
switchedOff.add(name);
continue;
}
if (!(name in union)) union[name] = def;
else if (fingerprint(union[name]) !== fingerprint(def)) conflicts.add(name);
}
}
const disabled = [...switchedOff].filter((n) => !(n in union)).sort();
for (const s of state) {
if (s.res.status !== 'ok') continue;
const add = dict<McpServer>();
for (const name of Object.keys(union)) {
if (s.names.has(name)) continue;
if (canExpress(s.t.format, union[name])) add[name] = union[name];
else s.res.skipped.push(name);
}
s.res.added = Object.keys(add);
if (!opts.apply || s.res.added.length === 0) continue;
try {
// Re-read right before writing: claude rewrites ~/.claude.json constantly.
const fresh = await readText(s.file);
const out = addServers(s.t.format, fresh, add);
const current = parseConfig(s.t.format, fresh);
const written = Object.keys(add).filter((n) => !current.names.has(n));
if (written.length === 0) {
s.res.added = [];
continue;
}
const subset = dict<McpServer>();
for (const n of written) subset[n] = add[n];
await writeAtomic(s.file, out, carriesSecrets(subset));
s.res.added = written;
} catch (err) {
s.res.status = 'failed';
s.res.error = describeMcpSyncError(err);
s.res.added = [];
}
}
return {
applied: opts.apply,
targets: state.map((s) => s.res),
conflicts: [...conflicts].sort(),
disabled,
unsupported,
};
}
+27 -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;
@@ -108,6 +115,8 @@ export interface CreateSessionOptions {
envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel;
/** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */
advisorModel?: string;
/** tmux history-limit (scrollback lines) allocated when this session is created. */
historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */
@@ -123,8 +132,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;
@@ -150,6 +166,8 @@ export interface RespawnPaneOptions {
unsetEnvKeys?: string[];
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel;
/** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */
advisorModel?: string;
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */
@@ -322,6 +340,14 @@ export interface TerminalMultiplexer extends EventEmitter {
*/
getPaneExit?(muxName: string): PaneExit | undefined;
/**
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
* reports, or 0 when it reports none. The exited-agent sweep closes a session
* only once this reaches `CLEAN_EXIT_CONFIRMING_READS` (`pane-exit-sweep.ts`),
* and a multiplexer without this method never has a session closed by it.
*/
getPaneExitReadCount?(muxName: string): number;
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
clearPaneExit?(muxName: string): void;
+99
View File
@@ -0,0 +1,99 @@
/**
* @fileoverview The exited-agent sweep's decision rule (Ark0N/Codeman#446).
*
* Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the
* CLI while the pane, the tmux session and the `tmux attach-session` process
* all live on. Part 1 of #446 records that as `SessionState.paneExit`. This
* module decides when such a session is closed, the way the X button closes
* it, so finished sessions stop piling up on the board.
*
* The rule closes a session only on a POSITIVE observation of a clean exit:
*
* - The exit status must be an explicit numeric 0 with no signal. An absent
* status is UNKNOWN, never 0: on tmux 3.2a a SIGKILLed pane reports neither a
* status nor a signal, so reading absence as clean would sweep an agent the
* OOM killer took. A non-zero status or any signal keeps the row, marked with
* the exit, as the crash evidence #210 was filed to keep.
* - At least {@link CLEAN_EXIT_CONFIRMING_READS} authoritative pane reads must
* have agreed on that exit. A failed, empty or skipped read counts for
* nothing, because unknown never closes anything.
* - No start, attach or relaunch may be in flight for the session. The
* dead-pane branch of `Session._setupOrAttachMuxSession()` respawns an exited
* pane on purpose, and for a few seconds that pane still reads as dead.
* - The exit must land at least {@link CLEAN_EXIT_MIN_PANE_LIFETIME_MS} after
* the last start, attach or relaunch finished. A CLI that prints a startup
* error ("not logged in", a bad profile, a config error) and exits 0 would
* otherwise lose its tab, and the error with it, seconds after launch. Its
* row stays, marked `exited (0)`, for the user to read and close.
*
* Scoping to local mux-backed sessions happens before this rule runs:
* `Session.setPaneExit()` forces the field to UNKNOWN for direct-PTY, remote,
* docker and discovered sessions, so their `paneExit` never reaches here.
*
* Pure, so the rule is unit-tested without a server (test/pane-exit-sweep.test.ts).
*/
import type { PaneExit } from './types/index.js';
/**
* How many authoritative pane reads must agree on a clean exit before the
* session is closed. At the watcher's 2 s cadence two reads mean a finished
* session disappears within about four seconds of its agent exiting.
*/
export const CLEAN_EXIT_CONFIRMING_READS = 2;
/**
* How long a pane must have been up before a clean exit closes its session.
* An exit sooner than this after the last pane start is read as a startup
* failure rather than a user ending the agent, and the row is kept.
*/
export const CLEAN_EXIT_MIN_PANE_LIFETIME_MS = 10_000;
/** The lifecycle-log reason recorded when the sweep closes a session. */
export const CLEAN_EXIT_CLOSE_REASON = 'agent exited cleanly (status 0)';
/**
* Is this exit a clean one? True only for an explicit numeric status of 0 with
* no signal reported.
*
* ⚠ Never widen this to `(exit.status ?? 0) === 0` or to "no signal, so it was
* clean". An absent status is how a signal death presents on tmux 3.2a, and
* that shortcut would close crashed agents with nothing failing to warn you.
*/
export function isCleanPaneExit(exit: PaneExit | undefined): boolean {
if (!exit) return false;
if (exit.signal !== undefined) return false;
return exit.status === 0;
}
/** Everything the sweep needs to know about one session. */
export interface CleanExitSweepCandidate {
/** The session's published exit, already scoped by `Session.setPaneExit()`. */
paneExit: PaneExit | undefined;
/** Authoritative pane reads that agreed on that exit (`getPaneExitReadCount()`). */
confirmingReads: number;
/** A start, attach or relaunch is running for this session's pane. */
paneLifecycleInFlight: boolean;
/** The session is already being closed or detached. */
closing: boolean;
/**
* When the last start, attach or relaunch of this pane finished
* (`Session.paneStartedAt`), or 0 when none has run in this process.
*/
paneStartedAt: number;
}
/** Should the sweep close this session now? See the file overview for the rule. */
export function shouldCloseCleanlyExitedSession(candidate: CleanExitSweepCandidate): boolean {
if (candidate.closing) return false;
if (candidate.paneLifecycleInFlight) return false;
if (!isCleanPaneExit(candidate.paneExit)) return false;
// `at` is when this server first read the pane dead, so an exit during the
// start itself lands BEFORE `paneStartedAt` and is kept too.
if (
candidate.paneStartedAt > 0 &&
candidate.paneExit!.at - candidate.paneStartedAt < CLEAN_EXIT_MIN_PANE_LIFETIME_MS
) {
return false;
}
return candidate.confirmingReads >= CLEAN_EXIT_CONFIRMING_READS;
}
+27 -14
View File
@@ -19,19 +19,22 @@
* touching its status, so a pinned session a reboot killed still reads `idle` or
* `busy` and stays eligible.
*
* ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT
* recognise today, and a reboot restores it. `/exit` ends the CLI inside the
* pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the
* `tmux attach-session` process, which stays alive throughout — so no exit
* handler runs, no lifecycle `exit` is logged, and the record keeps both its pid
* and `status: 'idle'`. Nothing durable distinguishes it from a session that was
* simply idle when the power went. Ark0N/Codeman#446 covers making Codeman
* notice the dead pane; until a record can say the agent is gone, this pass will
* offer those sessions back, and the user dismisses or closes them.
* ⚠️ Ending the AGENT rather than the session leaves no trace in `status` or
* `pid`. `/exit` ends the CLI inside the pane, `remain-on-exit` keeps the pane,
* and the PTY Codeman owns is the `tmux attach-session` process, which stays
* alive throughout — so no exit handler runs, no lifecycle `exit` is logged,
* and the record keeps both its pid and `status: 'idle'`. Ark0N/Codeman#446
* handles it in two steps. The pane-exit watcher persists `paneExit`, and the
* clean-exit sweep (`pane-exit-sweep.ts`) closes a session whose agent exited
* with status 0 through `cleanupSession()`, which leaves the durable record
* described above. This module also refuses a record whose persisted
* `paneExit` is a clean exit, which covers a session that exited moments
* before the power went, before the sweep reached it. A crashed agent's record
* stays eligible, like the row the sweep leaves on the board for it.
*
* The `pid` check below is therefore NOT that rule. It refuses a record whose
* attach process was already gone, which is a session that never started or
* whose pane died outright.
* The `pid` check below is NOT that rule. It refuses a record whose attach
* process was already gone, which is a session that never started or whose
* pane died outright.
*
* @dependencies types (SessionState), config/cli-registry
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
@@ -41,6 +44,7 @@
import type { SessionState } from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { isCleanPaneExit } from './pane-exit-sweep.js';
/** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
const RESTORABLE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy', 'error']);
@@ -109,7 +113,7 @@ export function resolveResumeConversationId(state: SessionState): string {
/**
* Why one session was passed over. Reported for logging and shown to the user.
*
* The first seven are decided before anything is built. `capacity-reached` and
* All but the last two are decided before anything is built. `capacity-reached` and
* `rebuild-failed` can only happen once a click is spending the plan, and they
* are the two the banner must not confuse with a missing workspace: one means
* "try again after closing something", the other means the CLI would not start.
@@ -120,6 +124,7 @@ export interface RebootRestoreRejection {
| 'no-persisted-record'
| 'intentionally-ended'
| 'not-running'
| 'agent-exited'
| 'respawn-blocked'
| 'remote-or-docker'
| 'unsupported-mode'
@@ -191,7 +196,8 @@ export function planRebootRestore(
//
// ⚠️ This does NOT catch a session the user ended with `/exit`. See the
// module header: that leaves the pid in place, because the pid is the tmux
// attach process and `remain-on-exit` keeps it alive.
// attach process and `remain-on-exit` keeps it alive. The `paneExit` check
// below catches it instead.
//
// Conservative on purpose. A session that somehow persisted no pid while
// genuinely running is not offered, and its conversation stays reachable
@@ -200,6 +206,13 @@ export function planRebootRestore(
skipped.push({ sessionId, reason: 'not-running' });
continue;
}
if (isCleanPaneExit(state.paneExit)) {
// The user ended the agent, and the clean-exit sweep would have closed the
// session had the power not gone first (Ark0N/Codeman#446). The same
// explicit-0 rule applies: an absent status is unknown, not clean.
skipped.push({ sessionId, reason: 'agent-exited' });
continue;
}
if (state.respawnBlocked === true) {
// The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
skipped.push({ sessionId, reason: 'respawn-blocked' });
+56
View File
@@ -158,3 +158,59 @@ export function watchingLabel(
}
return null;
}
/**
* How many rows above the composer the turn's closing row may sit. Between the two Claude
* draws only its composer border and, sometimes, a right-aligned hint
* (`new task? /clear to save 169.1k tokens`), so this leaves room for a blank row or two
* and no more. A bound, not a tuning knob: the walk must never reach far enough up the
* transcript to find an old turn's row.
*/
export const AWAITING_SEARCH_ROWS = 6;
/** A row that opens with a box-drawing character is the composer's frame, not transcript. */
const COMPOSER_FRAME_ROW = /^[─-╿]/;
/**
* Whether the pane's newest turn ended by handing off to workers the CLI will wait for,
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`.
*
* Such a pane is quiet and shows its composer, so every other signal calls it idle, yet
* nothing is being asked of the user: the CLI resumes by itself when the workers report
* back. That is why a session in this state counts as working.
*
* ⚠️ The row is a snapshot. Claude renders it once, at the end of the turn, and never
* updates it, so after the workers finish the same words are still on screen above the
* follow-up turn. Matching them anywhere on the pane would pin the session busy for as
* long as they stay visible. Only the newest transcript row counts: the walk starts at
* the composer (the LAST row carrying `promptGlyph`), steps up past blank rows, the
* composer's frame and anything indented (a right-aligned hint, a wrapped continuation),
* and tests the first row that starts in column 0. A follow-up turn always puts rows of
* its own there, so the stale copy is never the one tested.
*
* @param promptGlyph the CLI's composer glyph (`capabilities.workDetect.promptGlyph`)
* @returns false when the screen shows no composer, which is no evidence either way
*/
export function isAwaitingWorkers(paneText: string | null | undefined, pattern: RegExp, promptGlyph: string): boolean {
if (!paneText) return false;
const rows = stripAnsi(paneText)
.split('\n')
.map((row) => row.trimEnd());
let composer = -1;
for (let i = rows.length - 1; i >= 0; i--) {
// Claude has drawn its composer both bare (`❯ …` between rules) and boxed (`│ ❯ … │`).
if (rows[i].replace(/^[\s│]+/, '').startsWith(promptGlyph)) {
composer = i;
break;
}
}
if (composer < 0) return false;
for (let i = composer - 1; i >= Math.max(0, composer - AWAITING_SEARCH_ROWS); i--) {
const row = rows[i];
if (row === '' || /^\s/.test(row) || COMPOSER_FRAME_ROW.test(row)) continue;
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
pattern.lastIndex = 0;
return pattern.test(row);
}
return false;
}
+33 -3
View File
@@ -9,7 +9,7 @@
*/
import type { ClaudeMode, EffortLevel } from './types.js';
import { isEffortLevel } from './types.js';
import { isAdvisorModel, isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
import { compareVersions } from './utils/dependency-checker.js';
import { dataPath } from './config/instance.js';
@@ -54,6 +54,25 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
}
/**
* The `--settings` keys that switch on Claude Code's advisor tool for one session: a
* stronger model the main model consults at decision points (code.claude.com/docs/en/advisor).
* Returns `{}` for an absent or non-allowlisted value, so callers can spread it unconditionally.
*
* ⚠️ Carried as the `advisorModel` SETTINGS key, never the `--advisor` flag. The flag EXITS at
* launch on any pairing the CLI refuses (`claude --advisor haiku` prints "cannot be used as an
* advisor" and exits 1, as does a Fable advisor still awaiting usage-credit consent), which
* would leave a dead pane on every spawn and respawn. The settings key degrades instead: the
* CLI simply does not attach an advisor it cannot use. It is a SOFT default either way:
* `/advisor` still switches or turns it off inside the running session.
*
* ⚠️ Claude Code reads only ONE `--settings` flag per invocation, so this must be merged into
* the same JSON object as ultracode and the statusLine exporter, never rendered on its own.
*/
export function buildAdvisorSettings(advisorModel?: string): { advisorModel?: string } {
return isAdvisorModel(advisorModel) ? { advisorModel } : {};
}
/**
* Minimum Claude CLI version for passing `--name` at spawn. 2.1.224 is the release
* that ships cross-session messaging (the feature that makes the peer name matter),
@@ -111,6 +130,7 @@ export function buildNameCliArgs(sessionName: string | undefined, cliVersion: st
* @param effort - Optional effort level, injected via --settings (overridable in-session)
* @param sessionName - Optional Codeman session name, passed as `--name` (version-gated)
* @param cliVersion - Installed Claude CLI version for the `--name` gate (null = omit the flag)
* @param advisorModel - Optional advisor model, merged into the one `--settings` JSON (see buildAdvisorSettings)
* @returns Array of CLI arguments
*/
export function buildInteractiveArgs(
@@ -120,11 +140,21 @@ export function buildInteractiveArgs(
allowedTools?: string,
effort?: EffortLevel,
sessionName?: string,
cliVersion?: string | null
cliVersion?: string | null,
advisorModel?: string
): string[] {
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
if (model) args.push('--model', model);
args.push(...buildEffortCliArgs(effort));
const effortArgs = buildEffortCliArgs(effort);
const advisor = buildAdvisorSettings(advisorModel);
if (advisor.advisorModel === undefined) {
args.push(...effortArgs);
} else if (effortArgs[0] === '--settings') {
// One --settings flag only: fold the advisor into ultracode's JSON object.
args.push('--settings', JSON.stringify({ ...JSON.parse(effortArgs[1]), ...advisor }));
} else {
args.push(...effortArgs, '--settings', JSON.stringify(advisor));
}
args.push(...buildNameCliArgs(sessionName, cliVersion));
return args;
}
+17 -9
View File
@@ -20,7 +20,7 @@
import type { CliEntry } from './config/cli-registry/types.js';
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { compareVersions } from './utils/dependency-checker.js';
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
import { launcherDefaultTarget } from './utils/cli-launcher.js';
@@ -54,6 +54,8 @@ export interface SpawnBridgeOptions {
ompConfig?: OmpConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Claude advisor model; rides the same `--settings` JSON as ultracode (see buildAdvisorSettings). */
advisorModel?: string;
sessionName?: string;
claudeCliVersion?: string | null;
/**
@@ -198,14 +200,20 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
engineValues.effortLevel = effortValue;
}
// Fold the ephemeral plan-usage statusLine exporter (see resolveStatusLineCliCommand in
// hooks-config.ts) into the SAME `--settings` JSON object as ultracode/ effort, since Claude
// Code accepts only one `--settings` flag per invocation — rendering them as two independent
// params would let the second one silently win. Claude-only in practice (statusLineCommand
// is resolved claude-mode-only upstream), but this merge is mode-agnostic.
if ((effortFlag === '--settings' && effortValue) || options.statusLineCommand) {
const settingsObj: Record<string, unknown> =
effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {};
// Fold the advisor model and the ephemeral plan-usage statusLine exporter (see
// resolveStatusLineCliCommand in hooks-config.ts) into the SAME `--settings` JSON object as
// ultracode/ effort, since Claude Code accepts only one `--settings` flag per invocation:
// rendering them as independent params would let the last one silently win. Claude-only in
// practice: only claude's launch template renders this engine value, so another CLI's
// session carrying an advisorModel launches exactly as before.
// Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor
// byte-identical to one from before the advisor existed.
const advisorSettings = buildAdvisorSettings(options.advisorModel);
if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) {
const settingsObj: Record<string, unknown> = {
...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}),
...advisorSettings,
};
if (options.statusLineCommand) {
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
}
+158 -3
View File
@@ -42,6 +42,7 @@ import {
NiceConfig,
DEFAULT_NICE_CONFIG,
getErrorMessage,
isAdvisorModel,
isEffortLevel,
type ClaudeMode,
type SessionMode,
@@ -85,6 +86,7 @@ import {
isSustainedActivity,
isPaneQuiet,
watchingLabel,
isAwaitingWorkers,
WATCHING_TAIL_LINES,
IDLE_RECHECK_MS,
PANE_PROBE_MIN_INTERVAL_MS,
@@ -211,6 +213,21 @@ export function isExternalCliMode(mode: SessionMode): boolean {
return getCli(mode)?.capabilities.external ?? true;
}
/**
* Does this CLI take the top-level session `model` (claude's per-session `--model`)?
*
* Read off the registry's model-source capability: only a `claude-settings-file` CLI
* (claude) launches on that field. Every other CLI takes its model in its own config object
* (`codexConfig.model` and so on), so for them the field is inert, and cron hands the
* app-wide default (always a Claude id) to any CLI that has a model at all. `toState()`
* publishes and persists the field only where this holds, so a codex cron session never
* reports a Claude model it did not run on, and `POST /api/sessions` refuses a `model` for
* any CLI where it does not.
*/
export function cliTakesSessionModel(mode: SessionMode): boolean {
return getCli(mode)?.capabilities.model.source === 'claude-settings-file';
}
/** Display name for a run mode. Falls back to the raw id for an unregistered one. */
function getModeLabel(mode: SessionMode): string {
return getCli(mode)?.label ?? mode;
@@ -531,6 +548,8 @@ export class Session extends EventEmitter {
private _watchingLineRe: RegExp | null | undefined = undefined;
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
private _watchingWindow = WATCHING_TAIL_LINES;
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
private _awaitingLineRe: RegExp | null | undefined = undefined;
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
@@ -582,6 +601,21 @@ export class Session extends EventEmitter {
* rendered as "alive".
*/
private _paneExit: PaneExit | null = null;
/**
* How many starts, attaches or relaunches are running for this session's
* pane. While one is, a dead-pane reading may describe a pane that is being
* revived on purpose, so the exited-agent sweep leaves the session alone
* (Ark0N/Codeman#446). A counter rather than a flag, so two overlapping
* operations cannot clear each other's mark.
*/
private _paneLifecycleOps = 0;
/** When the last pane start, attach or relaunch finished (ms), 0 when none has run. */
private _paneStartedAt = 0;
/**
* The server has started closing this session, so no start or attach may
* begin (see {@link markClosing}).
*/
private _closing = false;
/**
* This session was rebuilt from the tmux socket rather than from Codeman's
* own records, so its `remote`/`docker` metadata is missing rather than known
@@ -640,6 +674,11 @@ export class Session extends EventEmitter {
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
private _effort: EffortLevel | undefined;
// Claude advisor model (code.claude.com/docs/en/advisor), merged into the same launch
// `--settings` JSON as ultracode, never the `--advisor` flag (which exits on a refused
// pairing). A soft default: /advisor still switches or disables it in-session.
private _advisorModel: string | undefined;
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`,
// `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via
// toState()/the customModel getter): they are what setCustomModel() needs to undo a
@@ -756,6 +795,8 @@ export class Session extends EventEmitter {
envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel?: string;
/** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */
@@ -916,6 +957,9 @@ export class Session extends EventEmitter {
if (config.effort && isEffortLevel(config.effort)) {
this._effort = config.effort;
}
if (isAdvisorModel(config.advisorModel)) {
this._advisorModel = config.advisorModel;
}
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
this._remote = config.remote;
this._docker = config.docker;
@@ -1183,6 +1227,49 @@ export class Session extends EventEmitter {
return this._paneExit ?? undefined;
}
/**
* True while a start, attach or relaunch is running for this session's pane.
* The exited-agent sweep reads it (see `pane-exit-sweep.ts`): the dead-pane
* branch of {@link _setupOrAttachMuxSession} respawns an exited pane, and
* until it finishes and clears the exit, the pane still reads as dead.
*/
get paneLifecycleInFlight(): boolean {
return this._paneLifecycleOps > 0;
}
/**
* When the last start, attach or relaunch of this pane finished, or 0 when
* none has run in this process. The exited-agent sweep keeps an exit that
* lands within `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` of it, since that reads as a
* CLI failing at startup rather than a user ending it. An attach to a pane
* that was already running stamps it too, which only costs a user who
* `/exit`s within seconds of a server restart a row to close by hand.
*/
get paneStartedAt(): number {
return this._paneStartedAt;
}
/**
* Mark this session as being closed, or clear the mark after a close that
* failed. While it is set, {@link startInteractive} and {@link startShell}
* refuse to run. A start that raced a close would otherwise launch a CLI in a
* tmux session whose record is about to be deleted (Ark0N/Codeman#446).
*/
markClosing(closing: boolean): void {
this._closing = closing;
}
/** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */
private async _withPaneLifecycle<T>(op: () => Promise<T>): Promise<T> {
this._paneLifecycleOps++;
try {
return await op();
} finally {
this._paneLifecycleOps--;
this._paneStartedAt = Date.now();
}
}
/**
* Forget this pane's exit, on both this record and the mux layer's cache.
* Every path that starts or relaunches a command in the pane calls it, and
@@ -1560,6 +1647,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);
}
@@ -1753,6 +1853,10 @@ export class Session extends EventEmitter {
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// Claude only: for any other CLI `_model` is inert (its model lives in its own config
// object) and may be the app-wide Claude default cron handed it.
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
advisorModel: this._advisorModel,
customModel: this.customModel,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
// intent before restarting a crash-looped session. Deliberately NOT restored
@@ -1875,6 +1979,14 @@ export class Session extends EventEmitter {
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string;
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
return this._withPaneLifecycle(() => this._doSetupOrAttachMuxSession(options));
}
private async _doSetupOrAttachMuxSession(options: {
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string;
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
const mux = this._mux!;
@@ -2058,6 +2170,10 @@ export class Session extends EventEmitter {
* the mux session is gone — see {@link reattachRemote} for that reasoning).
*/
async restartCli(): Promise<boolean> {
return this._withPaneLifecycle(() => this._doRestartCli());
}
private async _doRestartCli(): Promise<boolean> {
if (!this._useMux || !this._mux || !this._muxSession) return false;
const mux = this._mux;
@@ -2093,6 +2209,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,
@@ -2118,6 +2235,7 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides,
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
effort: this._effort,
advisorModel: this._advisorModel,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
@@ -2445,6 +2563,9 @@ export class Session extends EventEmitter {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
if (this._closing) {
throw new Error('Session is being closed');
}
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
// rather than at PTY spawn so a slow mux attach still counts as startup.
@@ -2561,6 +2682,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,
@@ -2576,6 +2698,7 @@ export class Session extends EventEmitter {
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
advisorModel: this._advisorModel,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
@@ -2699,8 +2822,9 @@ export class Session extends EventEmitter {
this._model,
this._allowedTools,
this._effort,
this._name,
getClaudeCliVersion()
this.cliPinnedName,
getClaudeCliVersion(),
this._advisorModel
);
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(getClaudeBinaryPath(), args, {
@@ -3005,7 +3129,10 @@ export class Session extends EventEmitter {
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
this._lastPaneProbeAt = now;
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
// A turn that ended by handing off to workers the CLI waits for is work too: the
// composer is up and the pane is quiet, but the next turn starts without the user.
this._lastPaneProbeWorking =
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
this._readWatching(text);
return this._lastPaneProbeWorking;
}
@@ -3063,6 +3190,21 @@ export class Session extends EventEmitter {
return this._watchingLineRe;
}
/**
* Whether the newest turn on this screen ended waiting for workers the CLI started
* (Claude's `✻ Waiting for 1 dynamic workflow to finish`). False for a CLI whose
* registry entry declares no `awaitingLine`. See `isAwaitingWorkers()`.
*/
private _paneAwaitsWorkers(paneText: string): boolean {
if (this._awaitingLineRe === undefined) {
const src = getCli(this.mode)?.capabilities.workDetect?.awaitingLine;
this._awaitingLineRe = src ? compileVersionRegex(src) : null;
}
if (!this._awaitingLineRe) return false;
const glyph = getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '❯';
return isAwaitingWorkers(paneText, this._awaitingLineRe, glyph);
}
/**
* The regex matching this CLI's "a turn is running" status line.
*
@@ -3265,6 +3407,9 @@ export class Session extends EventEmitter {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
if (this._closing) {
throw new Error('Session is being closed');
}
this._resetBuffers();
@@ -4095,6 +4240,16 @@ export class Session extends EventEmitter {
return false;
}
/**
* Arm the composer check for a prompt that went out some other way than
* `writeViaMux`, e.g. cron's paste mode, which writes the body raw and its Enter
* separately. `text` is what the composer line starts with while the prompt is still
* unsent; the check re-presses Enter only while that holds.
*/
verifySubmitted(text: string): void {
this._verifySubmitted(`${text}\r`);
}
/**
* Arm the composer check for a write that carried Enter (session-submit-verifier.ts):
* Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints,
+39 -6
View File
@@ -287,6 +287,19 @@ export interface PaneExitObservation {
exit: PaneExit;
}
/**
* A {@link PaneExitObservation} as the manager stores it, with a count of the
* authoritative reads that have seen this same exit. The count is what lets
* the exited-agent sweep act only on a death that more than one read agreed on
* (`CLEAN_EXIT_CONFIRMING_READS` in `pane-exit-sweep.ts`). A failed or skipped
* read never reaches {@link TmuxManager.applyPaneExits}, so it neither raises
* the count nor resets it.
*/
interface TrackedPaneExit extends PaneExitObservation {
/** Authoritative reads that saw this exit, counting the first. */
reads: number;
}
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
function paneField(fields: string[], index: number): number | undefined {
const raw = fields[index];
@@ -869,9 +882,11 @@ export function buildSpawnCommand(options: {
ompConfig?: OmpConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Claude advisor model, merged into the launch's one `--settings` JSON (see buildAdvisorSettings). */
advisorModel?: string;
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
statusLineCommand?: string;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
sessionName?: string;
/**
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
@@ -1672,7 +1687,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* lives on `Session`, because the remote-reconnect watcher above needs the
* raw pane reading.
*/
private paneExits: Map<string, PaneExitObservation> = new Map();
private paneExits: Map<string, TrackedPaneExit> = new Map();
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
private paneExitInterval: NodeJS.Timeout | null = null;
/**
@@ -2054,6 +2069,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
mode,
name,
cliName,
niceConfig,
model,
claudeMode,
@@ -2069,6 +2085,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId,
envOverrides,
effort,
advisorModel,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
@@ -2156,8 +2173,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig,
resumeSessionId,
effort,
advisorModel,
statusLineCommand,
sessionName: name,
sessionName: cliName,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -2383,9 +2401,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
envOverrides,
unsetEnvKeys,
effort,
advisorModel,
remote,
docker,
name,
cliName,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
@@ -2421,8 +2440,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig,
resumeSessionId,
effort,
advisorModel,
statusLineCommand,
sessionName: name,
sessionName: cliName,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
@@ -3074,6 +3094,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return this.paneExits.get(muxName)?.exit;
}
/**
* How many authoritative pane reads have agreed on the exit that
* {@link getPaneExit} reports, or 0 when it reports none. A new observation
* starts at 1, and every later read that sees the same pane with the same
* status and signal adds one.
*/
getPaneExitReadCount(muxName: string): number {
return this.paneExits.get(muxName)?.reads ?? 0;
}
/**
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
* `tmux list-panes -a` answers for every session at once, which is why this
@@ -3169,6 +3199,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* changed status, a changed signal, or a different pane pid all start a new
* observation — the pid is what catches a second command in the same pane
* that happened to exit the same way.
*
* The same rule decides the read count: a repeat of the stored exit adds one,
* and anything that starts a new observation starts the count again at 1.
*/
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
for (const muxName of [...this.paneExits.keys()]) {
@@ -3181,7 +3214,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
prev.panePid === next.panePid &&
prev.exit.status === next.exit.status &&
prev.exit.signal === next.exit.signal;
this.paneExits.set(muxName, sameExit ? prev : next);
this.paneExits.set(muxName, sameExit ? { ...prev, reads: prev.reads + 1 } : { ...next, reads: 1 });
}
}
+3 -1
View File
@@ -24,9 +24,10 @@
* | run-summary | RunSummary, RunSummaryEvent, RunSummaryStats | In-memory → `GET /api/sessions/:id/run-summary` |
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
* | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
* | push | PushSubscriptionRecord, VapidKeys, WebhookConfig, WebhookStatus, WebhookResult | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json`, `~/.codeman/webhook.json` |
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
*
* ## Cross-domain relationship map
*
@@ -72,3 +73,4 @@ export * from './search.js';
export * from './user.js';
export * from './webview.js';
export * from './intent.js';
export * from './mcp-sync.js';
+41
View File
@@ -0,0 +1,41 @@
/**
* @fileoverview Response types for MCP server sync (`GET`/`POST /api/mcp-sync`, src/mcp-sync.ts).
*
* These are returned over HTTP, so they carry server NAMES only: never env values or headers,
* and never file content (a parse failure is reported by position, see `describeMcpSyncError`).
*/
/** One participating CLI in a sync result. */
export interface McpSyncTargetResult {
id: string;
label: string;
/** The config file read (and written). For a `skipped` target, the unresolved location. */
file: string;
/**
* `absent`: not installed and no config file, so neither read nor created.
* `skipped`: the CLI's config location could not be resolved safely (e.g. its relocation env
* var is a relative path), so it is neither read nor written; `error` says why.
* `unreadable`: the file exists but cannot be parsed safely, so it is not written.
* `failed`: a read or write error (the file may be unchanged).
*/
status: 'ok' | 'absent' | 'skipped' | 'unreadable' | 'failed';
/** Why the target is not `ok`. Position or category only, never file content. */
error?: string;
servers: string[];
/** Servers added (apply) or that would be added (plan). */
added: string[];
/** Missing servers this dialect cannot express. */
skipped: string[];
}
/** The `data` of `GET`/`POST /api/mcp-sync`. */
export interface McpSyncResult {
applied: boolean;
targets: McpSyncTargetResult[];
/** Names defined differently by different CLIs; existing definitions are left untouched. */
conflicts: string[];
/** Names left out because the only definitions are switched off in their own CLI. */
disabled: string[];
/** Installed, enabled agent CLIs with no known MCP config file, so sync cannot touch them. */
unsupported: string[];
}
+43 -2
View File
@@ -6,13 +6,17 @@
* Key exports:
* - PushSubscriptionRecord — a registered push endpoint with per-event preferences
* - VapidKeys — VAPID key pair (public + private) for Web Push authentication
* - WebhookConfig, WebhookStatus, WebhookResult (+ the kind/scope lists): the webhook channel
* (ntfy, Slack, Discord, generic JSON) that carries the same events as Web Push
*
* Persistence:
* - VAPID keys: `~/.codeman/push-keys.json` (auto-generated on first use)
* - Subscriptions: `~/.codeman/push-subscriptions.json` (expired auto-cleaned on 410/404)
* - Webhook: `~/.codeman/webhook.json` (mode 0600; the URL is a bearer secret)
*
* Managed by PushStore (`src/push-store.ts`). Served at `GET /api/push/vapid-key`,
* `POST /api/push/subscribe`. No dependencies on other domain modules.
* Push is managed by PushStore (`src/push-store.ts`), served at `GET /api/push/vapid-key`,
* `POST /api/push/subscribe`. The webhook is managed by `src/webhook-notify.ts`, served at
* `GET`/`PUT /api/webhook` and `POST /api/webhook/test`. No dependencies on other domain modules.
*/
/** A registered push subscription */
@@ -32,3 +36,40 @@ export interface VapidKeys {
privateKey: string;
generatedAt: number;
}
/** Services the webhook channel can format a message for. */
export const WEBHOOK_KINDS = ['ntfy', 'slack', 'discord', 'generic'] as const;
export type WebhookKind = (typeof WEBHOOK_KINDS)[number];
/** `attention`: only events that need a human (critical / warning). `all`: also "response complete". */
export const WEBHOOK_SCOPES = ['attention', 'all'] as const;
export type WebhookScope = (typeof WEBHOOK_SCOPES)[number];
export type WebhookUrgency = 'critical' | 'warning' | 'info';
/** The stored webhook config (`~/.codeman/webhook.json`). `url` is a secret and is never returned. */
export interface WebhookConfig {
enabled: boolean;
kind: WebhookKind;
url: string;
scope: WebhookScope;
}
/** One delivery attempt. `error` never contains the URL. */
export interface WebhookResult {
ok: boolean;
status?: number;
error?: string;
at: number;
}
/** `GET /api/webhook`: the config without its URL, plus the last delivery result. */
export interface WebhookStatus {
enabled: boolean;
kind: WebhookKind;
scope: WebhookScope;
hasUrl: boolean;
/** Scheme + host only; the path and query are the secret. */
urlMasked: string;
lastResult: WebhookResult | null;
}
+51 -7
View File
@@ -12,7 +12,7 @@
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, reasoningEffort, resumeSessionId, bypass, animations, renderMode)
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
@@ -377,6 +377,36 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel {
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
}
/**
* Reasoning effort levels codex accepts as `model_reasoning_effort` (codex-cli 0.154.0).
* Which of them a given model honours is codex's business; Codeman only keeps the value
* to a known word, since it lands in the launch argv.
*/
export const CODEX_REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const;
/** Codex reasoning effort for a session, passed as `--config model_reasoning_effort=<level>` */
export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number];
/**
* Model aliases Claude Code accepts for its advisor tool (a stronger model the session's
* main model consults at decision points; code.claude.com/docs/en/advisor). Haiku is left
* out on purpose: it can call an advisor but never act as one.
*/
export const ADVISOR_MODEL_ALIASES = ['fable', 'opus', 'sonnet'] as const;
/** A full model id in one of the advisor-capable families, e.g. `claude-opus-5-5`. */
const ADVISOR_MODEL_ID_PATTERN = /^claude-(?:fable|opus|sonnet)-[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Type guard: is the value an advisor model Codeman will pass to claude? An alias from
* ADVISOR_MODEL_ALIASES or a full fable/opus/sonnet model id. ⚠️ This allowlist is also the
* injection guard: the value is rendered inside the single-quoted `--settings` JSON argument.
*/
export function isAdvisorModel(value: unknown): value is string {
if (typeof value !== 'string' || value.length > 64) return false;
return (ADVISOR_MODEL_ALIASES as readonly string[]).includes(value) || ADVISOR_MODEL_ID_PATTERN.test(value);
}
/** OpenCode session configuration */
export interface OpenCodeConfig {
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
@@ -398,6 +428,8 @@ export type CodexRenderMode = 'hybrid';
export interface CodexConfig {
/** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */
model?: string;
/** Reasoning effort for this session. Passed via --config model_reasoning_effort=<level>. */
reasoningEffort?: CodexReasoningEffort;
/** Resume a previous codex conversation by session id (passed via --resume) */
resumeSessionId?: string;
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
@@ -649,12 +681,13 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read
* "no signal was reported" as "the exit must have been clean". On tmux 3.2a
* the absent status IS how a signal death presents, so absent-stays-absent is
* the only thing keeping a future clean-exit sweep away from crashed agents:
* an agent SIGKILLed by the OOM killer would otherwise read as a user typing
* `/exit` and be swept. Nothing here fails when somebody adds that `??` — the
* types allow it, the label still renders, and the damage shows up only once
* the sweep lands. The rule is enforced in `derivePaneExits()`
* (`tmux-manager.ts`), which omits the key rather than defaulting it.
* the only thing keeping the clean-exit sweep (`pane-exit-sweep.ts`) away
* from crashed agents: an agent SIGKILLed by the OOM killer would otherwise
* read as a user typing `/exit` and be closed. Nothing here fails when
* somebody adds that `??` — the types allow it and the label still renders.
* The rule is enforced in `derivePaneExits()` (`tmux-manager.ts`), which omits
* the key rather than defaulting it, and again in `isCleanPaneExit()`, which
* accepts only an explicit 0.
*/
export interface PaneExit {
/**
@@ -794,6 +827,17 @@ export interface SessionState {
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** Claude advisor model (`advisorModel` in the launch `--settings`, switchable in-session via /advisor) */
advisorModel?: string;
/**
* The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or
* the app-wide default when there was none. Persisted so a recovered session relaunches on
* the same model rather than whatever the default is by then. Not `cliModel`, which is what
* the CLI's banner reports. Claude sessions only (`cliTakesSessionModel()`): every other CLI
* keeps its model in its own config object (`codexConfig.model` and so on), and this is
* absent for them.
*/
model?: string;
/**
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom
* OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed
+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);
}
+69 -1
View File
@@ -12,7 +12,8 @@
* image dir.
*/
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { realpathSync } from 'node:fs';
import { join, resolve } from 'node:path';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -53,6 +54,73 @@ export async function sweepPasteImagesOnce(
return { scanned, deleted };
}
/**
* The path two sessions must share to share a paste-image dir: the canonical
* path when it can be resolved, so a sibling that reaches the same directory
* through a symlink matches, and the normalised path otherwise (a directory
* that no longer exists has nothing left to protect).
*/
function canonicalDir(dir: string): string {
try {
return realpathSync(dir);
} catch {
return resolve(dir);
}
}
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
export interface PasteImageDirUser {
id: string;
workingDir: string;
status?: string;
}
/**
* Does another live session still use this working directory's paste-image
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
* and several sessions routinely share one case directory, so without this
* check closing one session deletes the pasted images a sibling in the same
* case still refers to.
*
* Two kinds of sibling count as live:
*
* - a session in the server's map, unless it is itself being killed;
* - a persisted record whose status is not `stopped`. That covers a session
* detached with `killMux=false`, which leaves the server's map while its
* tmux pane keeps running, and a session whose detach is still in progress.
*
* A session being KILLED does not count. Without that exemption, killing two
* sessions of one case concurrently (a bulk delete, or the exited-agent sweep
* closing two panes on one tick) would have each defer to the other, and
* neither would remove the dir.
*
* Erring toward "in use" only costs a missed deletion, which the periodic
* sweep above ages out. A pinned record whose tmux session is gone keeps its
* status through boot pruning, so it holds the dir this way until unpinned.
*/
export function pasteImageDirInUseByOtherSession(input: {
live: Iterable<PasteImageDirUser>;
persisted: Iterable<PasteImageDirUser>;
closingId: string;
workingDir: string;
killing: ReadonlySet<string>;
}): boolean {
const target = canonicalDir(input.workingDir);
const matches = (user: PasteImageDirUser): boolean =>
user.id !== input.closingId &&
!input.killing.has(user.id) &&
!!user.workingDir &&
canonicalDir(user.workingDir) === target;
for (const user of input.live) {
if (matches(user)) return true;
}
for (const user of input.persisted) {
if (user.status === 'stopped') continue;
if (matches(user)) return true;
}
return false;
}
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
const initial = setTimeout(() => {
void sweepPasteImagesOnce(ctx);
+936 -48
View File
File diff suppressed because it is too large Load Diff
+190 -2
View File
@@ -658,6 +658,123 @@ function sortSessionsByActivity(rows) {
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
}
// Tab grouping by state (`tabGrouping: 'state'`, Discussion #426 option C).
//
// The tab list answers "who wants me?" the way the home screens do: a row (the
// header strip) or a section (the flat side rail, the sidebar) per state, most
// urgent on top. The states are the home screens' own, from
// `_mobileOverviewState()` (mobile-overview.js); this only folds the six into
// four groups a strip can hold:
//
// needs red: a permission or question dialog is blocking the agent. A
// failed session joins it, since it also needs a human and the home
// screens rank it right below.
// waiting yellow: the agent finished its turn and is waiting on you.
// working a turn is running.
// idle everything quiet: idle, ended, and an agent that exited inside a
// live pane (#446), which may still read as working on screen but is
// running nothing. Web tabs close the group.
//
// Applied as the flex `order` property, never by reordering the DOM, the same
// design as the sorted rail (`_tabRailSortOrder`, app.js): `#sessionTabs` stays
// in `sessionOrder`, so Alt+N, drag-and-drop and the keyboard walk keep reading
// the list they always read, and a state change moves one inline style instead
// of rebuilding the strip. Each group owns a band of `TAB_TRIAGE_STRIDE` order
// values: its heading at the start of the band, its rows after it, its web tabs
// after those and the line break that ends the header row at the very end.
//
// Pure: no DOM, no `this`. Unit-tested in test/tab-triage.test.ts.
const TAB_TRIAGE_GROUPS = [
{ key: 'needs', label: 'Needs you' },
{ key: 'waiting', label: 'Waiting' },
{ key: 'working', label: 'Working' },
{ key: 'idle', label: 'Idle' },
];
const TAB_TRIAGE_GROUP_OF_STATE = {
needs: 'needs',
error: 'needs',
waiting: 'waiting',
working: 'working',
idle: 'idle',
done: 'idle',
};
const TAB_TRIAGE_STRIDE = 10000;
/** Offset of a group's web tabs inside its band, past any plausible session count. */
const TAB_TRIAGE_WEB_OFFSET = 5000;
/**
* Which group a session belongs to.
* @param {string} state a `_mobileOverviewState()` value
* @param {boolean} exited the agent inside the pane has exited (`_mobileOverviewExit()` non-null)
* @returns {'needs'|'waiting'|'working'|'idle'}
*/
function tabTriageGroupFor(state, exited) {
const group = TAB_TRIAGE_GROUP_OF_STATE[state] || 'idle';
return exited && group === 'working' ? 'idle' : group;
}
/**
* Order values and visible groups for one pass.
*
* @param {Array<{id: string, state: string, exited?: boolean, pos?: number}>} rows
* live sessions; `pos` ranks a row inside its group (tab order on the header
* strip, the activity sort's position on a sorted rail). Rows without one keep
* the order they were passed in.
* @param {Array<string>} webviewIds open web tabs, in their own tab order
* @returns {{
* order: Map<string, number>,
* webOrder: Map<string, number>,
* groups: Array<{key: string, label: string, count: number, headOrder: number, breakOrder: number}>
* }} `groups` lists only the non-empty groups, most urgent first.
*/
function computeTabTriageLayout(rows, webviewIds) {
const list = Array.isArray(rows) ? rows : [];
const webs = Array.isArray(webviewIds) ? webviewIds : [];
const baseOf = {};
const counts = {};
TAB_TRIAGE_GROUPS.forEach((group, i) => {
baseOf[group.key] = (i + 1) * TAB_TRIAGE_STRIDE;
counts[group.key] = 0;
});
const placed = list
.filter((row) => row && typeof row.id === 'string')
.map((row, i) => ({
id: row.id,
group: tabTriageGroupFor(row.state, !!row.exited),
pos: Number.isFinite(row.pos) ? row.pos : i,
index: i,
}));
const byGroup = {};
for (const row of placed) (byGroup[row.group] = byGroup[row.group] || []).push(row);
const order = new Map();
for (const key of Object.keys(byGroup)) {
// Stable: equal positions keep the order the caller passed.
byGroup[key].sort((a, b) => a.pos - b.pos || a.index - b.index);
byGroup[key].forEach((row, i) => order.set(row.id, baseOf[key] + 1 + i));
counts[key] = byGroup[key].length;
}
const webOrder = new Map();
webs.forEach((id, i) => {
if (typeof id === 'string' && id) webOrder.set(id, baseOf.idle + TAB_TRIAGE_WEB_OFFSET + i);
});
counts.idle += webOrder.size;
const groups = TAB_TRIAGE_GROUPS.filter((group) => counts[group.key] > 0).map((group) => ({
key: group.key,
label: group.label,
count: counts[group.key],
headOrder: baseOf[group.key],
breakOrder: baseOf[group.key] + TAB_TRIAGE_STRIDE - 1,
}));
return { order, webOrder, groups };
}
// Terminal font stack — the single source for every xterm surface (the main
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
@@ -762,6 +879,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 +1100,17 @@ if (typeof window !== 'undefined') {
compare: compareSessionActivity,
sort: sortSessionsByActivity,
};
window.CodemanTabTriage = {
GROUPS: TAB_TRIAGE_GROUPS,
STRIDE: TAB_TRIAGE_STRIDE,
groupFor: tabTriageGroupFor,
layout: computeTabTriageLayout,
};
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,
@@ -1410,7 +1581,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -1428,7 +1599,7 @@ function absoluteFilePathPattern() {
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
*/
const FILE_PREVIEW_EXTENSIONS = new Set(
('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
);
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
@@ -1805,10 +1976,27 @@ function reconcilePtyGeometry(local, pty) {
return { adopt: true, cols: pty.cols };
}
/**
* Which session does a dashboard URL's fragment ask for? Another page that
* holds the dashboard's window, such as a task board, points it at
* `/#session=<id>`. Only the fragment changes between two such links, so the
* browser keeps the page loaded and fires `hashchange`, and the dashboard
* switches tabs without reloading. Any other fragment asks for nothing.
*
* @param {string} hash - `location.hash`, with or without its leading `#`
* @returns {string|null} the session id, or null
*/
function sessionIdFromFragment(hash) {
const params = new URLSearchParams(String(hash || '').replace(/^#/, ''));
const id = params.get('session');
return id && id.trim() ? id.trim() : null;
}
if (typeof window !== 'undefined') {
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
window.CodemanTerminalLines = { terminalLogicalLine };
window.CodemanUrlSession = { sessionIdFromFragment };
window.CodemanSplitPane = {
clampDividerPercent,
buildSplitPickerSessions,
+11 -5
View File
@@ -201,6 +201,8 @@ Object.assign(CodemanApp.prototype, {
const session = this.sessions.get(id);
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
// Guarded: a stale cached mobile-overview.js may predate the helper.
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
const mode = session.mode || 'claude';
return {
id,
@@ -211,7 +213,10 @@ Object.assign(CodemanApp.prototype, {
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
// What the row's dot, accent and pill show. It differs from `state` only
// for an exited agent (Ark0N/Codeman#446), whose state still sorts it.
display: exit ? 'exited' : state,
pill: exit ? 'exited' : HOME_SESSIONS_PILL_LABEL[state] || state,
// What the pane's footer says is still running in the background, straight off
// the session payload. Same field, same meaning as on the phone overview.
watching: typeof session.watching === 'string' ? session.watching : '',
@@ -224,7 +229,7 @@ Object.assign(CodemanApp.prototype, {
lastSubmitAt: Number(session.lastSubmitAt) || 0,
// "how long has it been like this", resolved by the phone overview's
// helper so both home screens label the same stamp with the same word.
since: this._mobileOverviewSince(state, session),
since: exit ? exit.since : this._mobileOverviewSince(state, session),
};
});
@@ -392,7 +397,8 @@ Object.assign(CodemanApp.prototype, {
_buildHomeSessionRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'home-sessions-row home-sessions-row--' + row.state;
const display = row.display || row.state;
item.className = 'home-sessions-row home-sessions-row--' + display;
item.dataset.hsAction = 'session';
item.dataset.hsSession = row.id;
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
@@ -409,7 +415,7 @@ Object.assign(CodemanApp.prototype, {
}
const dot = document.createElement('span');
dot.className = 'home-sessions-dot home-sessions-dot--' + row.state;
dot.className = 'home-sessions-dot home-sessions-dot--' + display;
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
@@ -441,7 +447,7 @@ Object.assign(CodemanApp.prototype, {
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'home-sessions-pill home-sessions-pill--' + row.state;
pill.className = 'home-sessions-pill home-sessions-pill--' + display;
// Skipped by i18n on purpose: generic single words ("idle", "done", "error")
// that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', '');
+11
View File
@@ -67,6 +67,7 @@
'Open away digest': '打开离开期间摘要',
'Session Manager': '会话管理器',
'Session actions': '会话操作',
Ungrouped: '未分组',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
@@ -106,16 +107,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': '新建案例',
@@ -198,6 +203,7 @@
Running: '运行中',
Idle: '空闲',
Working: '工作中',
Waiting: '等待中',
Today: '今天',
Home: '主页',
Local: '本地',
@@ -567,6 +573,8 @@
'Task Complete': '任务完成',
'Copied to clipboard': '已复制到剪贴板',
'Nothing to copy': '没有可复制的内容',
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
'Session not found': '未找到会话',
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
Copy: '复制',
@@ -723,6 +731,9 @@
'Source type filter': '来源类型筛选',
'Copy content': '复制内容',
'Edit file': '编辑文件',
'Rendered markdown': '渲染 Markdown',
'Line numbers': '行号',
'Wrap lines': '自动换行',
'Unsaved changes': '未保存的更改',
Saved: '已保存',
'Export as JSON': '导出为 JSON',
+212 -62
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate
here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';}</script>
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';document.documentElement.dataset.tabGrouping=(A.tabGrouping==='none')?'none':'state';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabGrouping='state';document.documentElement.dataset.headerStats='classic';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -154,6 +154,8 @@
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
<span class="connection-dot" id="connectionDot"></span>
<span class="connection-text" id="connectionText"></span>
<span class="connection-tile-label" id="connectionTileLabel"></span>
<span class="connection-tile-value" id="connectionTileValue"></span>
</div>
<div class="header-font-controls">
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
@@ -166,6 +168,7 @@
<div class="stat-bar">
<div class="stat-bar-fill stat-bar-cpu" id="statCpuBar"></div>
</div>
<span class="stat-spark" id="statCpuSpark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span class="stat-value" id="statCpu">--%</span>
</div>
<div class="stat-item">
@@ -173,6 +176,7 @@
<div class="stat-bar">
<div class="stat-bar-fill stat-bar-mem" id="statMemBar"></div>
</div>
<span class="stat-spark" id="statMemSpark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span class="stat-value" id="statMem">--</span>
</div>
</div>
@@ -451,42 +455,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>
@@ -604,6 +577,9 @@
<div class="file-preview-header">
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
<div class="file-preview-actions">
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">&#x2398;</button>
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
@@ -648,39 +624,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
@@ -1876,6 +1826,22 @@
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Key tester</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="key tester keyboard shift enter newline diagnose keydown keypress">
<div class="set-row-text">
<span class="set-row-label">Key tester</span>
<span class="set-row-desc">Click the box and press keys to see what this browser reports (key, code, modifiers) for keydown, keypress and keyup. Useful when a shortcut such as Shift+Enter behaves differently on one device. Nothing is sent to a session.</span>
</div>
<input type="text" id="keyTesterInput" class="set-input" data-raw-keys readonly autocomplete="off" spellcheck="false"
placeholder="Click here, then press keys"
onkeydown="app.keyTesterEvent(event)" onkeypress="app.keyTesterEvent(event)" onkeyup="app.keyTesterEvent(event)">
</div>
<pre id="keyTesterLog" class="set-note mono" style="display:none;white-space:pre-wrap" data-i18n-skip></pre>
</div>
</div>
</section>
<!-- ══ Header &amp; Panels ═══════════════════════════════════════ -->
@@ -1936,6 +1902,17 @@
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
</div>
<div class="set-row has-field" data-search="header stats style system cpu mem ws plan usage tiles compact rings sparkline">
<div class="set-row-text">
<span class="set-row-label">Header Stats Style <span class="set-tag">desktop</span></span>
<span class="set-row-desc">How WS, CPU, MEM and the plan-usage windows are drawn. Compact is one system pill with sparklines plus usage rings; Tiles put each label over its value with a bar underneath.</span>
</div>
<select id="appSettingsHeaderStatsStyle" class="set-select">
<option value="classic">As before (bars)</option>
<option value="compact">Compact (pill + rings)</option>
<option value="tiles">Tiles (default)</option>
</select>
</div>
</div>
</div>
@@ -2078,6 +2055,16 @@
<option value="vertical">Vertical (side rail)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab grouping group by state triage needs you waiting working idle rows sections">
<div class="set-row-text">
<span class="set-row-label">Tab Grouping</span>
<span class="set-row-desc">By state puts what needs you on top: a row each for needs you (red), waiting (yellow), working and idle in the header, and the same sections in the side rail and sidebar. Tabs move between groups as their state changes; Alt+1..9 keeps the tab order. Named tab groups in the side rail take precedence.</span>
</div>
<select id="appSettingsTabGrouping" class="set-select">
<option value="state">By state (default)</option>
<option value="none">None (tab order)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail detail rows created working idle status pill simple">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Rows</span>
@@ -2091,7 +2078,7 @@
<div class="set-row has-field" data-search="tab rail sort order activity manual drag reorder">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Order</span>
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. Alt+1..9 always follows the tab order either way.</span>
<span class="set-row-desc">By activity uses the home screen's order: blocked on you first, then whatever has been running longest, then the most recently quiet. Manual keeps your tab order and is the only mode you can drag rows in. With Tab Grouping by state it orders the rows inside each section. Alt+1..9 always follows the tab order either way.</span>
</div>
<select id="appSettingsTabRailSort" class="set-select">
<option value="activity">By activity (home screen order)</option>
@@ -2194,6 +2181,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 +2193,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>
@@ -2224,6 +2213,19 @@
<option value="ultracode">Ultracode</option>
</select>
</div>
<div class="set-row set-row-block" data-search="advisor fable opus sonnet second opinion review consult">
<div class="set-row-text">
<span class="set-row-label">Advisor</span>
<span class="set-row-desc">A stronger model Claude consults before big decisions, on repeated errors and before calling a task done. Uses extra tokens. Switchable in-session with /advisor.</span>
</div>
<div class="set-segment" id="appSettingsAdvisorSegment" role="radiogroup" aria-label="Advisor"></div>
<select id="appSettingsClaudeAdvisor" class="set-select set-field-hidden" aria-hidden="true" tabindex="-1">
<option value="">Default</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="fable">Fable</option>
</select>
</div>
</div>
</div>
@@ -2246,6 +2248,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 +2264,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 +2276,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 +2288,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 +2300,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 +2377,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">
@@ -2463,6 +2528,30 @@
</div>
</div>
</div>
<div class="set-group" id="mcpSyncGroup">
<div class="set-group-head"><h4>MCP servers</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="mcp server sync enable claude codex gemini opencode antigravity">
<div class="set-row-text">
<span class="set-row-label">Enable MCP server sync</span>
<span class="set-row-desc">Adds a control that copies MCP servers between your enabled CLIs by writing their own config files. Off by default: this changes other tools' configuration, not just Codeman's.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsMcpSync" onchange="app.applyMcpSyncVisibility()"><span class="slider"></span></label>
</div>
<div class="set-row" id="mcpSyncActionRow" style="display:none" data-search="mcp server sync preview">
<div class="set-row-text">
<span class="set-row-label">Sync MCP servers across CLIs</span>
<span class="set-row-desc">Copies each installed, enabled CLI's MCP servers into the others. Only adds missing servers; never edits, removes or copies a server you switched off. Env values and headers are copied too, so a file that receives them is left readable by you only. The previous file is kept as <code>.codeman-bak</code> (overwritten by each sync). A config dir moved by the CLI's own env var (<code>CODEX_HOME</code>, <code>CLAUDE_CONFIG_DIR</code>, <code>XDG_CONFIG_HOME</code>, <code>GEMINI_CLI_HOME</code>) is followed as Codeman's server sees it; a per-session override is not.</span>
</div>
<span>
<button class="btn-toolbar btn-sm" id="mcpSyncPreviewBtn" onclick="app.mcpSync(false)">Preview</button>
<button class="btn-toolbar btn-sm btn-primary" id="mcpSyncApplyBtn" onclick="app.mcpSync(true)">Sync now</button>
</span>
</div>
<div id="mcpSyncResult" class="set-note" style="display:none"></div>
</div>
</div>
</section>
<!-- ══ Notifications ════════════════════════════════════════════ -->
@@ -2590,6 +2679,57 @@
</div>
</div>
</div>
<div class="set-group" id="webhookGroup" style="display:none">
<div class="set-group-head"><h4>Webhook (ntfy, Slack, Discord)</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="webhook ntfy slack discord notification phone headless">
<div class="set-row-text">
<span class="set-row-label">Send alerts to a webhook</span>
<span class="set-row-desc">Posts the same events as push notifications (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any URL, so a server with no browser open can still reach your phone. The URL is a secret: it is stored on the server only and is never shown again once saved. On public ntfy.sh anyone who guesses the topic can read it, so pick a long random one.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="webhookEnabled"><span class="slider"></span></label>
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Service</span></div>
<select id="webhookKind" class="set-select">
<option value="ntfy">ntfy</option>
<option value="slack">Slack</option>
<option value="discord">Discord</option>
<option value="generic">Generic JSON</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Webhook URL</span>
<span class="set-row-desc" id="webhookUrlHint">Nothing saved yet.</span>
</div>
<input type="password" id="webhookUrl" class="set-input" autocomplete="off" spellcheck="false" placeholder="https://ntfy.sh/your-topic">
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Which events</span>
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
</div>
<select id="webhookScope" class="set-select">
<option value="attention">Needs attention</option>
<option value="all">Everything</option>
</select>
</div>
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Save and test</span>
<span class="set-row-desc">The main Settings Save saves this group too. Send test saves pending edits first.</span>
</div>
<span>
<button class="btn-toolbar btn-sm btn-primary" id="webhookSaveBtn" onclick="app.saveWebhook()">Save</button>
<button class="btn-toolbar btn-sm" id="webhookTestBtn" onclick="app.testWebhook()">Send test</button>
<button class="btn-toolbar btn-sm" id="webhookClearBtn" onclick="app.clearWebhook()" style="display:none">Remove URL</button>
</span>
</div>
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
</section>
<!-- ══ Voice ════════════════════════════════════════════════════ -->
@@ -3167,6 +3307,7 @@
<h2>Manage</h2>
</div>
<p class="set-section-blurb">Reorder or remove cases, and pick up anything exported from a docker case.</p>
<input type="search" id="caseManageSearch" class="set-input" placeholder="Search cases by name or path" autocomplete="off" spellcheck="false" aria-label="Search cases" oninput="app.setCaseManageFilter(this.value)" style="margin-bottom: 8px;">
<div class="case-manage-list" id="caseManageList">
<!-- Populated by JS -->
</div>
@@ -3196,7 +3337,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>
@@ -3721,10 +3867,14 @@
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<!-- Shows iOS Safari IME composition text inside the terminal. Must precede terminal-ui.js. -->
<script defer src="mobile-ime-preview.js"></script>
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
<script defer src="terminal-keycode229-recovery.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
<!-- Owner tab layout projection (grouped vertical rail); pure, read by app.js. -->
<script defer src="tab-layout-browser.js"></script>
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script>
+297
View File
@@ -0,0 +1,297 @@
/**
* @fileoverview In-terminal preview of IME composition text on iOS Safari.
*
* On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese,
* Korean, dictation) is not visible inside the terminal until it commits, so
* the user types blind. The controller listens to the helper textarea's
* composition events and asks the caller to render the latest composition
* (`phase: 'provisional'`), coalesced to one render per animation frame and
* capped at 2048 characters. When xterm emits the committed text through
* onData, the caller hands it to `consumeTerminalData()`, which switches the
* preview to `phase: 'committed'` until something else shows the text: the
* local echo overlay or a prediction (`completeCommit`), authoritative
* terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer. The
* same 2 s bound applies while waiting for a commit that never reaches onData
* (the user deleted the whole composition), so a later unrelated chunk is never
* mistaken for it.
*
* Keydown ordering: xterm registers its textarea keydown listener in the
* capture phase inside terminal.open() and finalizes the composition there
* (CompositionHelper.keydown), emitting the commit through onData
* synchronously. The controller therefore observes keydown in the capture
* phase on an ANCESTOR (`keydownTarget`, the terminal element), which runs
* before any listener on the textarea itself, and finalizes on exactly the
* keys xterm does.
*
* VISUAL ONLY: the controller never sends, consumes or reorders input bytes,
* and every callback is wrapped so a failing render cannot block the wire.
* `isIosWebKitTouch()` gates creation; other platforms keep xterm's own
* composition view untouched.
*
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
* @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller)
*/
(function (global) {
'use strict';
const COMMITTED_VISUAL_TTL = 2000;
const PREVIEW_CAP = 2048;
// keyCodes on which xterm 6's CompositionHelper.keydown keeps composing
// (CapsLock, the IME "composition character", Shift/Ctrl/Alt). Any other
// keydown during a composition finalizes it.
const KEEP_COMPOSING_KEYCODES = new Set([20, 229, 16, 17, 18]);
const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
function isIosWebKitTouch(nav = navigator) {
const userAgent = String(nav && nav.userAgent ? nav.userAgent : '');
const platform = String(nav && nav.platform ? nav.platform : '');
const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0);
const iosDevice = /iPhone|iPad|iPod/.test(userAgent);
const desktopIpad = platform === 'MacIntel' && touchPoints > 1;
return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad);
}
function create(options) {
const textarea = options.textarea;
// Must be the textarea or an ancestor of it, so its capture listener runs
// before xterm's capture listener on the textarea.
const keydownTarget = options.keydownTarget || textarea;
const render = typeof options.render === 'function' ? options.render : function () {};
const clear = typeof options.clear === 'function' ? options.clear : function () {};
const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {};
const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global);
const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global);
const setTimer = options.setTimer || global.setTimeout.bind(global);
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
let generation = 0;
let composing = false;
let awaitingCommit = false;
let committed = false;
let latestValue = '';
let renderPhase = null;
let frameToken = null;
let timerToken = null;
let finalizedByKeydown = false;
let destroyed = false;
let invokingClear = false;
function safely(callback, ...args) {
try {
return callback(...args);
} catch (_error) {
return undefined;
}
}
function cancelScheduledFrame() {
const token = frameToken;
frameToken = null;
if (token && token.id !== undefined) safely(cancelFrame, token.id);
}
function cancelCommittedTimer() {
const token = timerToken;
timerToken = null;
if (token && token.id !== undefined) safely(clearTimer, token.id);
}
function clearVisual() {
if (invokingClear) return;
invokingClear = true;
safely(clear);
invokingClear = false;
}
function cleanup() {
generation += 1;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = false;
latestValue = '';
renderPhase = null;
finalizedByKeydown = false;
clearVisual();
}
function scheduleLatestPreview(phase) {
if (destroyed) return;
renderPhase = phase;
if (frameToken) return;
const token = { generation, id: undefined };
frameToken = token;
const callback = function () {
if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return;
frameToken = null;
const value = latestValue.slice(0, PREVIEW_CAP);
const phaseToRender = renderPhase;
safely(render, { text: value, phase: phaseToRender });
};
const id = safely(scheduleFrame, callback);
if (frameToken === token) {
if (id === undefined) frameToken = null;
else token.id = id;
}
}
function beginComposition() {
cleanup();
if (destroyed) return;
composing = true;
}
function updateComposition(event) {
if (!composing) return;
latestValue = event.data == null ? '' : String(event.data);
scheduleLatestPreview('provisional');
}
function onComposingInput(event) {
if (!event.isComposing) return;
updateComposition({ data: event.data == null ? textarea.value : event.data });
}
function armFallbackTimer(isCurrent) {
const token = { generation, id: undefined };
timerToken = token;
const callback = function () {
if (destroyed || timerToken !== token || token.generation !== generation || !isCurrent()) return;
timerToken = null;
cleanup();
};
const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
if (timerToken === token) {
if (id === undefined) {
timerToken = null;
return false;
}
token.id = id;
}
return true;
}
function finalizeComposition(value, fromKeydown) {
if (!composing) return;
composing = false;
awaitingCommit = true;
committed = false;
finalizedByKeydown = fromKeydown;
latestValue = value == null ? latestValue : String(value);
scheduleLatestPreview('provisional');
// A commit that never reaches onData (the composition was deleted, so
// xterm emits nothing) must not leave the controller waiting forever.
cancelCommittedTimer();
const owner = generation;
armFallbackTimer(function () {
return awaitingCommit && generation === owner;
});
}
function onCompositionEnd(event) {
if (finalizedByKeydown) {
finalizedByKeydown = false;
return;
}
finalizeComposition(event.data, false);
}
// Mirrors CompositionHelper.keydown in @xterm/xterm 6.0.0
// (src/browser/input/CompositionHelper.ts:94-108): while composing, keyCode
// 20/229 and 16/17/18 keep the composition open and every other keyCode
// finalizes it. `isComposing` and `key` are deliberately not consulted,
// because xterm does not consult them.
function onKeydown(event) {
if (keydownTarget !== textarea && event.target !== textarea) return;
if (!composing || KEEP_COMPOSING_KEYCODES.has(event.keyCode)) return;
finalizeComposition(latestValue, true);
}
function reset() {
if (destroyed) return;
cleanup();
}
function consumeTerminalData(data) {
if (
destroyed ||
!awaitingCommit ||
typeof data !== 'string' ||
data.length === 0 ||
CONTROL_OR_LINE_BREAK.test(data)
) {
return false;
}
generation += 1;
const owner = generation;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = true;
latestValue = data;
renderPhase = 'committed';
safely(onCommit, data);
if (destroyed || generation !== owner || !committed) return true;
scheduleLatestPreview('committed');
if (destroyed || generation !== owner || !committed) return true;
const armed = armFallbackTimer(function () {
return committed;
});
if (!armed && !destroyed && generation === owner && committed) cleanup();
return true;
}
function completeCommit(result) {
if (destroyed || !result || result.predicted !== true || !committed) return;
cleanup();
}
function noteAuthoritativeOutput() {
if (destroyed || !committed) return;
cleanup();
}
const listeners = [
[textarea, 'compositionstart', beginComposition],
[textarea, 'compositionupdate', updateComposition],
[textarea, 'input', onComposingInput],
[textarea, 'compositionend', onCompositionEnd],
[keydownTarget, 'keydown', onKeydown, true],
[textarea, 'blur', reset],
];
for (const [target, type, listener, capture] of listeners) target.addEventListener(type, listener, capture);
function destroy() {
if (destroyed) return;
destroyed = true;
for (const [target, type, listener, capture] of listeners) target.removeEventListener(type, listener, capture);
cleanup();
}
return {
consumeTerminalData,
completeCommit,
noteAuthoritativeOutput,
reset,
destroy,
get state() {
return {
generation,
composing,
awaitingCommit,
committed,
latest: latestValue,
framePending: frameToken !== null,
timerPending: timerToken !== null,
};
},
};
}
global.MobileImePreview = { create, isIosWebKitTouch };
})(globalThis);
+61 -8
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',
@@ -143,6 +161,36 @@ Object.assign(CodemanApp.prototype, {
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
},
/**
* The exited-agent override for one row (Ark0N/Codeman#446), or null when
* the row shows its state as usual.
*
* The server publishes `session.paneExit` once the agent inside a local tmux
* pane has exited, while `status` stays `idle` or `busy` by design. So a row
* classified as idle or working may really be a pane with nothing running
* in it. This overrides what the row SHOWS, never its `state`: `state` still
* picks the section and the sort, the way `_sidebarRichRow()` (app.js) does
* for the detailed sidebar and rail. A pending alert still wins, because a
* human being blocked outranks the agent having exited.
*
* Shared by the phone overview, the desktop home rail and the rich tab rows,
* so the three cannot disagree about which sessions have exited.
*
* Guarded like every other cross-file call: `paneExitLabel()` lives in
* app.js, and a stale cached app.js must degrade to no override, not throw.
*
* @returns {{since: {key: string, at: number}|null}|null}
*/
_mobileOverviewExit(state, session) {
if (state !== 'idle' && state !== 'working') return null;
if (typeof paneExitLabel !== 'function' || !paneExitLabel(session.paneExit)) return null;
// `at` is when this server first saw the pane dead, which is what "exited
// 2m" should measure. A row without it shows no duration at all rather
// than a working or idle stamp that no longer describes the pane.
const at = Number(session.paneExit.at) || 0;
return { since: at ? { key: 'exited', at } : null };
},
/**
* Longest-prefix match of a workingDir against the case list, so a session
* started in a subdirectory still belongs to its case. Mirrors the matching in
@@ -184,6 +232,7 @@ Object.assign(CodemanApp.prototype, {
const rows = sessions.map((session) => {
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
const exit = this._mobileOverviewExit(state, session);
const orderIndex = order.indexOf(session.id);
return {
id: session.id,
@@ -192,7 +241,10 @@ Object.assign(CodemanApp.prototype, {
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
// What the row's dot, accent and pill show. It differs from `state` only
// for an exited agent, whose state still decides the section and sort.
display: exit ? 'exited' : state,
pill: exit ? 'exited' : MOBILE_OVERVIEW_PILL_LABEL[state] || state,
// What the pane's own footer says is still running in the background ("1 monitor",
// "2 shells"), straight off the session payload. A row that has one is quiet
// because the agent is waiting for that, not because it is waiting for you.
@@ -204,7 +256,7 @@ Object.assign(CodemanApp.prototype, {
// pair resolved for DISPLAY, and the two must not drift apart.
lastActivityAt: Number(session.lastActivityAt) || 0,
lastSubmitAt: Number(session.lastSubmitAt) || 0,
since: this._mobileOverviewSince(state, session),
since: exit ? exit.since : this._mobileOverviewSince(state, session),
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
};
});
@@ -527,7 +579,7 @@ Object.assign(CodemanApp.prototype, {
const runMode = document.createElement('span');
runMode.className = 'mobile-overview-run-mode';
runMode.setAttribute('data-i18n-skip', '');
runMode.textContent = MOBILE_OVERVIEW_RUN_MODES.find((m) => m.mode === mode)?.short || mode;
runMode.textContent = mobileOverviewRunModes().find((m) => m.mode === mode)?.short || mode;
run.appendChild(runMode);
group.appendChild(run);
@@ -582,7 +634,7 @@ Object.assign(CodemanApp.prototype, {
menu.className = 'mobile-overview-run-menu';
const current = this.runMode || 'claude';
for (const entry of MOBILE_OVERVIEW_RUN_MODES) {
for (const entry of mobileOverviewRunModes()) {
if (entry.mode !== 'shell' && !this.isCliAvailable(entry.mode)) continue;
const option = document.createElement('button');
option.type = 'button';
@@ -680,12 +732,13 @@ Object.assign(CodemanApp.prototype, {
_buildMobileOverviewRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'mobile-overview-row mobile-overview-row--' + row.state;
const display = row.display || row.state;
item.className = 'mobile-overview-row mobile-overview-row--' + display;
item.dataset.moAction = 'session';
item.dataset.moSession = row.id;
const dot = document.createElement('span');
dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state;
dot.className = 'mobile-overview-dot mobile-overview-dot--' + display;
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
@@ -718,7 +771,7 @@ Object.assign(CodemanApp.prototype, {
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state;
pill.className = 'mobile-overview-pill mobile-overview-pill--' + display;
// Skipped by i18n on purpose: the labels are generic single words ("idle",
// "done", "error") that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', '');
+136 -18
View File
@@ -332,6 +332,39 @@ html.mobile-init .file-browser-panel {
}
}
/* Edge fade for the phone's header tab strip (used in the block below).
Registered so the keyframes can interpolate them as lengths; @property is
only valid at the top level, hence out here. */
@property --tab-strip-fade-start {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
@property --tab-strip-fade-end {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
/* Driven by the strip's own scroll position, not by time: at the start only the
end edge fades, at the end only the start edge, and in between both. */
@keyframes tab-strip-edge-fade {
0% {
--tab-strip-fade-start: 0px;
--tab-strip-fade-end: 28px;
}
10%,
90% {
--tab-strip-fade-start: 28px;
--tab-strip-fade-end: 28px;
}
100% {
--tab-strip-fade-start: 28px;
--tab-strip-fade-end: 0px;
}
}
/* ============================================================================
Phone Breakpoint (<600px)
============================================================================ */
@@ -652,7 +685,7 @@ html.mobile-init .file-browser-panel {
overscroll-behavior-x: contain;
scrollbar-width: none;
max-height: 36px;
gap: 2px;
gap: 6px;
padding: 0;
}
@@ -660,6 +693,36 @@ html.mobile-init .file-browser-panel {
display: none;
}
/* Fade the strip's edges while there is more to scroll to, so the tab that
does not fit dissolves into the edge instead of being cut mid-word against
the connection dot. Scroll-driven, no JS: the timeline is the strip's own
inline scroll (keyframes + registered properties above this block). A strip
that does not overflow has an INACTIVE timeline, so the animation applies
nothing and both widths stay at their registered 0px, which is no mask at
all. Browsers without scroll timelines skip the block and keep the hard
edge. Header only: in sidebar layout the same list scrolls vertically. */
@supports (animation-timeline: scroll()) {
.header .session-tabs {
-webkit-mask-image: linear-gradient(
to right,
transparent,
#000 var(--tab-strip-fade-start),
#000 calc(100% - var(--tab-strip-fade-end)),
transparent
);
mask-image: linear-gradient(
to right,
transparent,
#000 var(--tab-strip-fade-start),
#000 calc(100% - var(--tab-strip-fade-end)),
transparent
);
/* The shorthand resets animation-timeline, so the timeline comes after. */
animation: tab-strip-edge-fade linear both;
animation-timeline: scroll(self inline);
}
}
/* Smaller tabs for mobile */
.session-tab {
flex-shrink: 0;
@@ -671,10 +734,54 @@ html.mobile-init .file-browser-panel {
border-radius: 4px;
}
/* Smaller status indicator on mobile */
/* Every tab in the header strip is a chip, not only the active one. Left
transparent, the strip read as a row of disabled labels: grey 11px text
floating in unmarked gaps, with nothing saying "tap me". Fill and border
come from the skin's control tokens, so the four light skins (which repaint
the header with --glass-bg) get a matching chip with no override block, and
the active tab's !important fill and border in styles.css still win.
`:where(.header)` keeps this at (0,1,0): the per-colour left border
(`.session-tab[data-color="red"]`, (0,2,0)) must still outrank the
border-color here, and in sidebar layout the list leaves the header, so
its rows are untouched. */
:where(.header) .session-tab {
border-radius: 8px;
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
color: var(--text);
}
:where(.header) .session-tab .tab-name {
font-weight: 500;
}
/* Grouped by state (tabGrouping), the phone strip keeps its one scrolling
row: the tabs still come in state order, most urgent first, but the
headings would cost chips and the dots already say which state is which.
The phone overview is where the labelled sections live. */
:where(.header) .tab-triage-head {
display: none;
}
/* Only the active tab shows its action icons on a phone (see below), so on
every other tab the container is empty but still a flex item, and its gap
made the chip visibly wider on the right than on the left. */
:where(.header) .session-tab:not(.active) .tab-actions {
display: none;
}
/* The boxed digit is the Alt+1..9 shortcut hint. A phone has no Alt key, so
here it was only a second grey box inside every tab, and 20px of the name's
width. Every header tab is therefore numberless on a phone, which is the
case the active-tab reserve below is already sized for. */
:where(.header) .session-tab .tab-number {
display: none;
}
/* Status dot: 6px so an idle green reads at arm's length (4px was a speck). */
.session-tab .tab-status {
width: 4px;
height: 4px;
width: 6px;
height: 6px;
}
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
@@ -710,9 +817,12 @@ html.mobile-init .file-browser-panel {
opacity: 0.5;
}
/* Truncate tab names more aggressively on mobile */
/* Truncate tab names on mobile. 80px, not the old 50px: session names share
a `w1-` style prefix, and at 50px "w1-ingest-pipeline" became "w1-inge…"
and a clipped tab just "w1-", which says nothing about which session it is.
The 20px the hidden tab number gave back pays for most of the difference. */
.session-tab .tab-name {
max-width: 50px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -726,20 +836,19 @@ html.mobile-init .file-browser-panel {
difference instead, which costs a little strip space on exactly one tab
and keeps tap-to-switch the majority of it.
⚠️ The floor is set by the 10th tab onward, NOT by the numbered tabs you
are looking at. `.tab-number` is rendered only for `_tabIdx < 9` (app.js),
so tab 10 loses 16px + a 4px gap off its left and its centre sits 10px
further right. The centre clears the icons when
⚠️ The floor is set by a NUMBERLESS tab. `.tab-number` is rendered only
for `_tabIdx < 9` (app.js), and the header hides it on phones altogether
(above), so every phone tab is that case now; a numbered one would sit 10px
further left and hide the problem. The centre clears the icons when
reserved > icons + rightEdge - leftRunUp - gap
= 50 + 9 - 17 - 4 = 38px
= 50 + 9 - 19 - 4 = 36px
with icons = gear 32 + close 20 - close's -2px margin, leftRunUp = border 1
+ padding 8 + status dot 4 + gap 4, and rightEdge = padding 8 + border 1.
Hit testing snaps to whole pixels, so 39px still lands on the gear: the
practical floor is 40px and 44px keeps 4px of headroom. A NUMBERED tab
clears it at 20px, so reasoning from the tabs on screen is exactly what
would put the centre back on the gear. Pinned by
+ padding 8 + status dot 6 + gap 4, and rightEdge = padding 8 + border 1.
Hit testing snaps to whole pixels, so a centre half a pixel short still
lands on the gear: the practical floor was measured at 40px (with the
older 4px dot) and 44px keeps headroom. Pinned by
test/mobile-tab-tap-zones.test.ts. */
.session-tab.active .tab-name {
min-width: 44px;
@@ -2165,9 +2274,11 @@ html.mobile-init .file-browser-panel {
background: rgba(0, 0, 0, 0.5);
}
/* The footer already reserves the home-indicator inset; padding the sheet too
counted it twice and left a dead band under Create New Case. */
.mobile-case-picker-sheet {
max-height: 60vh;
padding-bottom: var(--safe-area-bottom);
max-height: 80vh;
max-height: 80dvh;
animation: slideUp 0.2s ease-out;
}
@@ -2978,6 +3089,13 @@ html.mobile-init .file-browser-panel {
color: var(--green);
}
/* An exited agent (Ark0N/Codeman#446): neutral, since nothing is running behind
the row. The dot and the row take `--exited` too and keep their base rules. */
.mobile-overview-pill--exited {
border-color: var(--text-muted);
color: var(--text-muted);
}
/* Accent, and none of the three above: a session watching work it started itself
is not asking the user for anything, and red and yellow are what say it is. */
.mobile-overview-pill--watching {

Some files were not shown because too many files have changed in this diff Show More