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
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
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 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
103 changed files with 12794 additions and 448 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.33.3",
"version": "1.34.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+28
View File
@@ -1,5 +1,33 @@
# 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
+14 -7
View File
@@ -78,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.33.3 (must match `package.json`)
**Version**: 1.34.0 (must match `package.json`)
## Project Overview
@@ -137,7 +137,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **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`.
@@ -244,6 +245,8 @@ 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)
@@ -254,7 +257,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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)
@@ -319,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)
@@ -327,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)
@@ -367,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.
@@ -379,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).
@@ -430,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.
+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',
];
/**
+35
View File
@@ -713,6 +713,41 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
## 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
+12
View File
@@ -118,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:
@@ -249,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.
+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 -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
+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)
+59 -11
View File
@@ -6,15 +6,16 @@ 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 |
| 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 |
| 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 |
## Tab alerts
@@ -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
@@ -152,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).
@@ -162,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
+40 -2
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).
+16 -3
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.33.3",
"version": "1.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.33.3",
"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",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.33.3",
"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",
@@ -105,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.33.3",
"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
@@ -345,6 +345,13 @@ ESC=$(printf '\033')
`.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
@@ -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
+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',
+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
+11 -3
View File
@@ -345,6 +345,13 @@ ESC=$(printf '\033')
`.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
+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
+29
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,
@@ -377,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({
+35
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',
@@ -306,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.
@@ -486,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.
@@ -532,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: [
@@ -543,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' } },
],
@@ -620,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: {
@@ -721,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.
@@ -800,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.
+28
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>;
/**
@@ -511,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
+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,
};
}
+4
View File
@@ -115,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 */
@@ -164,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 */
+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 };
}
+34 -1
View File
@@ -42,6 +42,7 @@ import {
NiceConfig,
DEFAULT_NICE_CONFIG,
getErrorMessage,
isAdvisorModel,
isEffortLevel,
type ClaudeMode,
type SessionMode,
@@ -212,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;
@@ -658,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
@@ -774,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. */
@@ -934,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;
@@ -1827,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
@@ -2205,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,
@@ -2667,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,
@@ -2791,7 +2823,8 @@ export class Session extends EventEmitter {
this._allowedTools,
this._effort,
this.cliPinnedName,
getClaudeCliVersion()
getClaudeCliVersion(),
this._advisorModel
);
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(getClaudeBinaryPath(), args, {
+6
View File
@@ -882,6 +882,8 @@ 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;
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
@@ -2083,6 +2085,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId,
envOverrides,
effort,
advisorModel,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
@@ -2170,6 +2173,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig,
resumeSessionId,
effort,
advisorModel,
statusLineCommand,
sessionName: cliName,
});
@@ -2397,6 +2401,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
envOverrides,
unsetEnvKeys,
effort,
advisorModel,
remote,
docker,
cliName,
@@ -2435,6 +2440,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig,
resumeSessionId,
effort,
advisorModel,
statusLineCommand,
sessionName: cliName,
});
+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;
}
+44 -1
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) */
@@ -795,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
+702 -18
View File
@@ -319,6 +319,8 @@ const _SSE_HANDLER_MAP = [
// Session order (global tab order sync, COD-131)
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
// Owner tab layout (grouped vertical rail)
[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged'],
// Web tabs (dashboard URLs)
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
@@ -581,6 +583,12 @@ class CodemanApp {
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
this.draggedTabId = null; // Currently dragged tab session ID
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
this.tabLayout = null;
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
this._lastTabGroupStructureKey = null;
this.cases = [];
this.currentRun = null;
this.totalTokens = 0;
@@ -1242,6 +1250,12 @@ class CodemanApp {
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
// A field that exists to show what a key does (Settings → Key tester, `data-raw-keys`) must
// receive every chord untouched. Without this, probing Ctrl+W killed the active session,
// Ctrl+L cleared the terminal and Escape closed Settings: this listener runs in the capture
// phase, before the field's own handler. Must stay the first statement.
if (e.target?.closest?.('[data-raw-keys]')) return;
// Don't intercept keys during CJK IME composition
if (e.isComposing || e.keyCode === 229) return;
@@ -3194,14 +3208,20 @@ class CodemanApp {
// broken feature rather than as an idle window (reported 2026-09-01). A
// missing CODEX bucket means the opposite — that plan has no such limit —
// so those stay omitted rather than showing a dash forever.
// Every window also carries a ring (the Compact header style) and a meter
// (Tiles). styles.css hides both in the classic style, so the chip there
// reads exactly as before. `fill` is clamped for the two graphics only; the
// label keeps the real number.
const seg = (label, p, idle) => {
if (p === null) {
if (!idle) return '';
return `<span class="pu-win pu-win-idle"><span class="pu-label">${label}</span><span class="pu-val">—</span></span>`;
return `<span class="pu-win pu-win-idle"><span class="pu-ring" style="--pu:0"></span><span class="pu-label">${label}</span><span class="pu-val">—</span><span class="pu-meter"><i style="width:0%"></i></span></span>`;
}
const n = Math.round(Number(p));
if (!Number.isFinite(n)) return '';
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
const fill = Math.min(100, Math.max(0, n));
const cls = colorClass(n);
return `<span class="pu-win"><span class="pu-ring ${cls}" style="--pu:${fill}"></span><span class="pu-label">${label}</span><span class="pu-val ${cls}">${n}%</span><span class="pu-meter ${cls}"><i style="width:${fill}%"></i></span></span>`;
};
// The provider label only earns its space when there is more than one
// provider to tell apart: a machine with Claude alone shows bare windows.
@@ -3986,6 +4006,36 @@ class CodemanApp {
};
}
/**
* The two words the Tiles header style shows for the connection indicator
* (label over value), derived from the descriptor rather than added to it,
* so the descriptor and its pinned strings stay exactly what they were. The
* classic text line (queued bytes and all) stays in the DOM and the full
* detail stays in the tooltip.
* @param {{dotClass: string, text: string}} desc
* @returns {{label: string, value: string, state: string}}
*/
_connectionTileWords(desc) {
const state = (desc.dotClass || '').replace('connection-dot', '').trim();
const text = desc.text || '';
switch (state) {
case 'connected':
return { label: 'WS', value: 'live', state };
case 'fallback':
return { label: 'HTTP', value: 'fallback', state };
case 'offline':
return { label: 'NET', value: 'offline', state };
case 'draining':
return { label: 'SEND', value: 'queued', state };
case 'reconnecting':
// The same dot covers the terminal WebSocket and, with no session
// open, the SSE event stream; the classic text already tells them apart.
return { label: text.startsWith('WS') ? 'WS' : 'SSE', value: 'retry', state };
default:
return { label: '', value: '', state };
}
}
_updateConnectionIndicator() {
const indicator = this.$('connectionIndicator');
const dot = this.$('connectionDot');
@@ -4013,6 +4063,14 @@ class CodemanApp {
dot.className = next.dotClass;
text.textContent = next.text;
indicator.title = next.title;
const tileLabel = this.$('connectionTileLabel');
const tileValue = this.$('connectionTileValue');
if (tileLabel && tileValue) {
const words = this._connectionTileWords(next);
tileLabel.textContent = words.label;
tileValue.textContent = words.value;
tileValue.className = `connection-tile-value ${words.state}`.trim();
}
}
}
@@ -4361,6 +4419,10 @@ class CodemanApp {
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
// (Re)read the owner tab layout on every init, including SSE reconnects: a
// tab:layoutChanged sent while this client was disconnected is never replayed.
this._loadTabLayout();
if (data.respawnStatus) {
this.respawnStatus = data.respawnStatus;
} else {
@@ -4713,6 +4775,128 @@ class CodemanApp {
return out;
}
/**
* True when the tab list groups by state (`tabGrouping: 'state'`, the default;
* Discussion #426 option C): a row per state in the header strip, a section
* per state in the flat side rail and the sidebar, most urgent on top.
*
* Read off <html> like the rail gates (applyTabOrientation() owns the
* attribute). Named groups in the vertical rail still win, because they are
* the user's own structure: `_tabTriageLayout()` returns null while the
* grouped projection is on, and the grouped tree renders as it always did.
*/
isTabTriage() {
return document.documentElement.dataset.tabGrouping === 'state';
}
/**
* Order values and visible groups for one render pass, or null when the list
* is not grouped by state. The pure core is `CodemanTabTriage.layout()`
* (constants.js); this only feeds it the same classification both home
* screens and the sorted rail use.
*
* Inside a group a row keeps its tab order on the header strip, so the strip
* only moves a tab when its state changes. A sorted rail ranks rows inside
* each section the way it ranks the whole flat rail (`railSortOrder`).
*
* @param {Array<string>} ids live session ids, in tab order
* @param {object|null} groupProjection the grouped rail's projection, if any
* @param {Map<string, number>|null} railSortOrder `_tabRailSortOrder(ids)`
*/
_tabTriageLayout(ids, groupProjection, railSortOrder) {
if (groupProjection || !this.isTabTriage()) return null;
if (!window.CodemanTabTriage || typeof this._mobileOverviewState !== 'function') return null;
const rows = [];
for (let i = 0; i < ids.length; i++) {
const session = this.sessions.get(ids[i]);
if (!session) continue;
const state = this._mobileOverviewState(session, this.pendingHooks?.get(ids[i]));
rows.push({
id: ids[i],
state,
exited: !!this._mobileOverviewExit?.(state, session),
pos: railSortOrder?.has(ids[i]) ? railSortOrder.get(ids[i]) : i,
});
}
const webviewIds = (this.webviewOrder || []).filter((wid) => this.webviews?.has(wid));
return window.CodemanTabTriage.layout(rows, webviewIds);
}
/**
* Keep the state headings, the row breaks and the web tabs' `order` in step
* with one pass's triage layout. Runs after BOTH render paths, because a
* session changing state is an incremental pass (no tab is added or removed)
* and can still empty a group or fill a new one.
*
* Headings and breaks are keyed by group and reconciled in place, never
* rebuilt, so an SSE tick that changes nothing writes nothing. They are
* direct children of #sessionTabs placed purely by `order`, so where they sit
* in the DOM does not matter, and `aria-hidden` keeps them out of the tablist,
* whose children must all be tabs. (A tab's state is not announced either way:
* its status dot is aria-hidden, as before.) With `triage` null this removes
* them all and clears the web tabs' inline order, which is what leaves the
* ungrouped strip exactly as it was.
*/
_syncTabTriageChrome(container, triage) {
if (!container) return;
container.classList.toggle('tabs-triage', !!triage);
const wanted = new Map((triage?.groups || []).map((group) => [group.key, group]));
for (const el of [...container.querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')]) {
if (!wanted.has(el.dataset.triageGroup)) el.remove();
}
const ensure = (className, key) => {
let el = container.querySelector(`:scope > .${className}[data-triage-group="${key}"]`);
if (!el) {
el = document.createElement('div');
el.className = className === 'tab-triage-head' ? `tab-triage-head tab-triage-head--${key}` : className;
el.dataset.triageGroup = key;
el.setAttribute('aria-hidden', 'true');
container.appendChild(el);
}
return el;
};
for (const group of wanted.values()) {
const head = ensure('tab-triage-head', group.key);
if (!head.firstElementChild) {
const label = document.createElement('span');
label.className = 'tab-triage-label';
label.textContent = group.label;
const count = document.createElement('span');
count.className = 'tab-triage-count';
head.append(label, count);
}
const count = String(group.count);
if (head.lastElementChild.textContent !== count) head.lastElementChild.textContent = count;
const headOrder = String(group.headOrder);
if (head.style.order !== headOrder) head.style.order = headOrder;
const brk = ensure('tab-triage-break', group.key);
const breakOrder = String(group.breakOrder);
if (brk.style.order !== breakOrder) brk.style.order = breakOrder;
}
for (const web of container.querySelectorAll(':scope > .session-tab[data-webview-id]')) {
const wid = web.dataset.webviewId;
const value = triage?.webOrder.has(wid) ? String(triage.webOrder.get(wid)) : '';
if (web.style.order !== value) web.style.order = value;
}
}
/**
* A drag in a state-grouped strip may only reorder WITHIN a group. Inside a
* group the rows sit in tab order, so a drop there moves the tab exactly where
* it was dropped; across groups the dragged tab would stay in its own group
* (its state did not change) and land somewhere the user did not put it.
* Groups are bands of `order` values, so comparing bands is enough.
*/
_isTabDropAcrossTriageGroups(targetTab) {
const container = this.$('sessionTabs');
if (!container?.classList.contains('tabs-triage') || !this.draggedTabId || !targetTab) return false;
const dragged = container.querySelector(`.session-tab[data-id="${this.draggedTabId}"]`);
if (!dragged) return false;
const stride = window.CodemanTabTriage?.STRIDE || 10000;
const band = (el) => Math.floor((Number(el.style.order) || 0) / stride);
return band(dragged) !== band(targetTab);
}
/**
* True where the sidebar is a MODAL off-canvas drawer over the terminal
* instead of a docked column.
@@ -4965,6 +5149,9 @@ class CodemanApp {
const reachable =
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
const needle = reachable ? this._sidebarFilter : '';
// State headings count the whole group, so they step aside while a filter
// is narrowing the rows under them (styles.css, .tabs-filtering).
container.classList.toggle('tabs-filtering', !!needle);
for (const tab of container.querySelectorAll('.session-tab')) {
if (!needle) {
tab.classList.remove('tab-filtered-out');
@@ -5183,6 +5370,12 @@ class CodemanApp {
_updateActiveTabImmediate(sessionId) {
const container = this.$('sessionTabs');
if (!container) return;
// Grouped rail: selecting a session hidden in a collapsed group must show it
// (and re-hide the previous exception), which a class toggle cannot do.
if (this._isTabGroupStructureStale()) {
this._fullRenderSessionTabs();
return;
}
const tabs = container.querySelectorAll('.session-tab[data-id]');
for (const tab of tabs) {
if (tab.dataset.id === sessionId) {
@@ -5191,6 +5384,7 @@ class CodemanApp {
tab.classList.remove('active');
}
}
this._syncTabTreeSelection(container);
// #257: selection used to stop at the class toggle. On phones/tablets the
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
@@ -5342,7 +5536,12 @@ class CodemanApp {
const container = this.$('sessionTabs');
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
const currentIds = new Set(this.sessions.keys());
// Grouped rail: a collapsed group keeps its rows out of the DOM, so compare
// against the rows the projection SHOWS, not every live session.
const groupProjection = this._projectTabGroups();
const currentIds = groupProjection
? new Set(groupProjection.visibleRefs.filter((ref) => ref.kind === 'session').map((ref) => ref.id))
: new Set(this.sessions.keys());
// Web tabs live in the same strip but are not in this.sessions, so they need
// their own change check. Without it, the session-only comparison below is
@@ -5351,14 +5550,20 @@ class CodemanApp {
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
t => t.dataset.webviewId
);
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const wantedWebIds = groupProjection
? groupProjection.visibleRefs.filter((ref) => ref.kind === 'webview').map((ref) => ref.id)
: (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const webTabsUnchanged =
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
// Check if we can do incremental update (same session IDs and same web tabs)
// The grouped rail's structure (sections, collapse, the shown exception) can
// change while the id sets stay equal; the in-place patch below cannot move
// or hide a row, so any structural change takes the full rebuild.
const canIncremental = existingIds.size === currentIds.size &&
[...existingIds].every(id => currentIds.has(id)) &&
webTabsUnchanged;
webTabsUnchanged &&
!this._isTabGroupStructureStale(groupProjection);
if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches
@@ -5368,7 +5573,13 @@ class CodemanApp {
// that sees one — a session going working→idle never adds or removes a
// tab, so the full rebuild below is not reached. Recomputed per pass for
// the same reason the rich meta line is: the order IS the state.
const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));
const liveIds = this.sessionOrder.filter((sid) => this.sessions.has(sid));
const railSortOrder = this._tabRailSortOrder(liveIds);
// Grouped by state: same reasoning, a state change moves a tab between
// rows. Its order values replace the rail sort's (which it already folded
// in as the rank inside each section).
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
const listOrder = triage ? triage.order : railSortOrder;
// Incremental update - only modify changed properties
for (const [id, session] of this.sessions) {
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
@@ -5376,7 +5587,7 @@ class CodemanApp {
// An empty string clears the property, which is also what un-sorts the
// rail when the setting (or the layout) flips without a full rebuild.
const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';
const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';
if (tab.style.order !== railOrder) tab.style.order = railOrder;
// A web tab owns the active state while one is open. activeSessionId stays
@@ -5545,6 +5756,16 @@ class CodemanApp {
subagentBadgeEl.remove();
}
}
// Grouped tree: the loop above can re-sort rows (`style.order`), move the
// highlight, and change the alerts a collapsed header stands in for, none
// of which rebuilds the rail. Keep what assistive tech and the headers
// report in step. The flat list (role=tablist) never takes this branch.
if (groupProjection && container.getAttribute('role') === 'tree') {
this._syncTabTreeSelection(container);
this._applyTabTreePositions(container);
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
this._syncTabTriageChrome(container, triage);
} else {
// Full rebuild needed (sessions added/removed)
this._fullRenderSessionTabs();
@@ -5613,6 +5834,15 @@ class CodemanApp {
return;
}
// Grouped by state, the header strip IS rows (one per state), so it always
// wraps: the row breaks only take effect in a wrapping flex line. Narrower
// screens keep the single scrolling row above, where the headings read as
// inline dividers instead.
if (container.classList.contains('tabs-triage')) {
container.classList.add('tabs-auto-wrap');
return;
}
// Measure the natural one-row overflow, then enable wrapping only if needed.
container.classList.remove('tabs-auto-wrap');
const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs
@@ -5676,6 +5906,12 @@ class CodemanApp {
const prevScrollTop = container.scrollTop;
const prevActiveTabId = this._lastRenderedActiveTabId;
const isFirstRender = !container.querySelector('.session-tab');
// The rebuild below destroys the focused row. In the grouped tree, put focus
// back on the same item (by identity) so a background render or a keyboard
// collapse does not drop a keyboard user to <body>.
const focusWasInside = container.contains(document.activeElement);
const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null);
this._tabFocusIdentity = null;
// Build tabs HTML using array for better string concatenation performance.
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
@@ -5695,12 +5931,23 @@ class CodemanApp {
// below still counts the strip, not the sorted list. Null in every other
// layout, and the tabs then carry no inline order at all — the header
// strip's markup is byte-identical to before.
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
const liveIds = tabOrder.filter((id) => this.sessions.has(id));
const railSortOrder = this._tabRailSortOrder(liveIds);
// Grouped by state (tabGrouping, the default): the same `order` mechanism,
// one band of values per state. Null in the grouped rail and with grouping
// off, and the rows then carry exactly the inline order they did before.
const groupProjection = this._projectTabGroups();
const triage = this._tabTriageLayout(liveIds, groupProjection, railSortOrder);
const listOrder = triage ? triage.order : railSortOrder;
// One row per session, in tab order. The flat strip emits them as-is; the
// grouped rail places the SAME markup into its sections, so a row never
// differs between the two (badge = Alt+N slot in sessionOrder either way).
const rowHtml = new Map();
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
if (!session) continue; // Skip if session was removed
const railOrderStyle = railSortOrder?.has(id) ? ` style="order:${railSortOrder.get(id)}"` : '';
const railOrderStyle = listOrder?.has(id) ? ` style="order:${listOrder.get(id)}"` : '';
// See the note in the incremental path: a web tab owns the active highlight
// while one is open, even though activeSessionId stays set.
@@ -5759,7 +6006,7 @@ class CodemanApp {
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -5782,12 +6029,41 @@ class CodemanApp {
_tabIdx++;
}
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
if (groupProjection) {
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
// every session), wherever their group puts them.
const webviewSlots = new Map(
(this.webviewOrder || []).filter((wid) => this.webviews?.has(wid)).map((wid, i) => [wid, _tabIdx + i])
);
parts.push(
window.CodemanTabLayout.renderProjection(
groupProjection,
(ref) =>
ref.kind === 'session'
? rowHtml.get(ref.id) || ''
: this.renderWebviewTab?.(ref.id, webviewSlots.get(ref.id) ?? Infinity) || '',
escapeHtml
)
);
this._hiddenTabGroupByRef = new Map(Object.entries(groupProjection.hiddenTabGroupByRef));
} else {
parts.push(...rowHtml.values());
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
this._hiddenTabGroupByRef = new Map();
}
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
container.innerHTML = parts.join('');
container.classList.toggle('session-tabs--grouped', !!groupProjection);
this._applyTabListRole(container, !!groupProjection);
if (groupProjection) {
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
this._syncTabTriageChrome(container, triage);
// Put the strip back where the user left it, then reveal the active tab
// only when it CHANGED (or on the first paint). Restoring unconditionally
@@ -5838,6 +6114,12 @@ class CodemanApp {
}
this._tabKeydownHandler = (e) => {
// The grouped rail is a tree with its own key model; everything else
// (header strip, sidebar, flat rail) keeps the tab-strip walk below.
if (container.getAttribute('role') === 'tree') {
this._handleTabTreeKeydown(e, container);
return;
}
// Up/Down are aliases of Left/Right, not replacements: the strip stays
// arrow-key navigable exactly as before, the vertical sidebar just gains
// the axis a user reaches for there.
@@ -5853,7 +6135,7 @@ class CodemanApp {
// the inline one, or web tabs (pinned past the cards by a CSS `order: 9999`
// rather than an inline style) read as 0 and the walk starts on them. Array
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
if (this.isTabRailSorted()) {
if (this.isTabRailSorted() || container.classList.contains('tabs-triage')) {
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
tabs.sort((a, b) => orderOf(a) - orderOf(b));
}
@@ -5862,8 +6144,7 @@ class CodemanApp {
// Enter or Space activates the tab
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
e.preventDefault();
const sessionId = tabs[currentIndex].dataset.id;
this.selectSession(sessionId, { forceReload: true });
this._activateTabRow(tabs[currentIndex]);
return;
}
@@ -5894,6 +6175,250 @@ class CodemanApp {
};
container.addEventListener('keydown', this._tabKeydownHandler);
// Grouped tree: a row or header focused by pointer becomes the tab stop, so
// Tab-ing away and back returns to it and there is still exactly one stop.
if (!this._tabTreeFocusinHandler) {
this._tabTreeFocusinHandler = (e) => {
if (container.getAttribute('role') !== 'tree') return;
const item = e.target?.closest?.('[role="treeitem"]');
if (item && container.contains(item)) this._setTabTreeStop(container, item);
};
container.addEventListener('focusin', this._tabTreeFocusinHandler);
}
}
/** Select a session row or open a web-tab row (Enter/Space, either layout). */
_activateTabRow(row) {
if (row?.dataset.webviewId) return this.openWebview(row.dataset.webviewId);
if (row?.dataset.id) return this.selectSession(row.dataset.id, { forceReload: true });
return undefined;
}
// ═══════════════════════════════════════════════════════════════
// Grouped rail: tree semantics and keyboard model
// ═══════════════════════════════════════════════════════════════
//
// Only the GROUPED vertical rail is a tree. #sessionTabs becomes role=tree;
// named-group headers are level-1 treeitems that own their rows (level 2);
// ungrouped rows and a collapsed group's kept selection are level 1. One item
// carries tabindex=0 (roving), every control inside a row is removed from the
// tab order, and focus is restored by identity across full re-renders. The
// flat rail and the header strip keep role=tablist / role=tab untouched.
/** #sessionTabs is a tablist (index.html) except while it holds the grouped tree. */
_applyTabListRole(container, grouped) {
if (grouped) {
container.setAttribute('role', 'tree');
container.setAttribute('aria-label', 'Sessions');
} else if (container.getAttribute('role') === 'tree') {
container.setAttribute('role', 'tablist');
container.setAttribute('aria-label', 'Session tabs');
}
}
/** Stable identity of a tree item (or anything inside one) across re-renders. */
_tabTreeIdentity(element) {
const item = element?.closest?.('[data-tab-group-header], .session-tab');
if (!item) return null;
if (item.dataset.tabGroupHeader) return `group:${item.dataset.tabGroupHeader}`;
if (item.dataset.webviewId) return `webview:${item.dataset.webviewId}`;
if (item.dataset.id) return `session:${item.dataset.id}`;
return null;
}
/**
* Visible tree items in the order the eye reads them: each section's header,
* then its rows. A sorted rail paints rows with the flex `order` property
* inside their own group column, so rows are ordered by COMPUTED order within
* a section (stable, so the unsorted rail keeps DOM order).
*/
_tabTreeItems(container) {
const sorted = this.isTabRailSorted();
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
const items = [];
for (const section of container.querySelectorAll('.tab-layout-group')) {
const header = section.querySelector(':scope > [role="treeitem"]');
if (header) items.push(header);
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
if (sorted) rows.sort((a, b) => orderOf(a) - orderOf(b));
items.push(...rows);
}
return items;
}
/** Move the single tab stop to `item` (every other tree item gets -1). */
_setTabTreeStop(container, item) {
for (const el of container.querySelectorAll('[role="treeitem"]')) el.tabIndex = el === item ? 0 : -1;
}
/**
* Turn freshly rendered rows into tree items and place the roving tab stop.
* Rows arrive as the flat strip's markup (role=tab, tabindex=0 on the row and
* its controls); only their semantics change here, never their content.
*/
_applyTabTreeSemantics(container, { identity = null, refocus = false } = {}) {
for (const row of container.querySelectorAll('.session-tab')) {
row.setAttribute('role', 'treeitem');
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
row.setAttribute('aria-level', row.closest('[role="group"]') ? '2' : '1');
// Controls stay clickable, but leave the tab order: the tree has ONE stop,
// and a row's actions are reachable from it with Shift+F10 / ContextMenu.
for (const control of row.querySelectorAll('[tabindex], button, a[href], input, select, textarea')) {
control.tabIndex = -1;
}
}
for (const header of container.querySelectorAll('[data-tab-group-header]')) header.setAttribute('aria-level', '1');
const items = this._applyTabTreePositions(container);
const byIdentity = (id) => (id ? items.find((item) => this._tabTreeIdentity(item) === id) : null);
// A focused row that a collapse just hid hands focus to its group header.
const hiddenIn = identity ? this._hiddenTabGroupByRef?.get(identity) : null;
const target =
byIdentity(identity) ||
(hiddenIn ? byIdentity(`group:${hiddenIn}`) : null) ||
items.find((item) => item.getAttribute('aria-selected') === 'true') ||
items[0];
if (!target) return;
this._setTabTreeStop(container, target);
if (refocus && document.activeElement !== target) target.focus();
}
/**
* aria-posinset / aria-setsize within each level, in PAINTED order: the
* level-1 run (headers, ungrouped rows, a collapsed group's kept row) and each
* group's own rows. Runs after every full render AND after an incremental pass,
* because the activity-sorted rail re-sorts rows in place. Returns the items.
*/
_applyTabTreePositions(container) {
const items = this._tabTreeItems(container);
const sets = new Map();
for (const item of items) {
const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container;
if (!sets.has(owner)) sets.set(owner, []);
sets.get(owner).push(item);
}
for (const members of sets.values()) {
members.forEach((item, index) => {
const setsize = String(members.length);
const posinset = String(index + 1);
if (item.getAttribute('aria-setsize') !== setsize) item.setAttribute('aria-setsize', setsize);
if (item.getAttribute('aria-posinset') !== posinset) item.setAttribute('aria-posinset', posinset);
});
}
return items;
}
/**
* A collapsed group hides its rows, including ones that need the user. Its
* header takes the most urgent hidden alert in the tab alert language
* (`tab-alert-action` red, `tab-alert-idle` yellow), so a permission prompt
* behind a collapse is never invisible. Patched in place on both render paths:
* alerts change without a rebuild.
*/
_syncTabGroupHeaderAlerts(container, projection) {
const alerts = window.CodemanTabLayout?.hiddenGroupAlerts(projection, (id) => this.tabAlerts?.get(id)) || {};
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
const alert = alerts[header.dataset.tabGroupHeader];
header.classList.toggle('tab-alert-action', alert === 'action');
header.classList.toggle('tab-alert-idle', alert === 'idle');
}
}
/** Keep aria-selected on the grouped tree in step with the .active class. */
_syncTabTreeSelection(container) {
if (container?.getAttribute('role') !== 'tree') return;
for (const row of container.querySelectorAll('.session-tab')) {
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
}
}
/**
* Keyboard model of the grouped tree (WAI-ARIA tree view): Up/Down walk the
* 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, Shift+F10 / ContextMenu open a row's actions.
*/
_handleTabTreeKeydown(e, container) {
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
const items = this._tabTreeItems(container);
const current = e.target?.closest?.('[role="treeitem"]');
// A key pressed on a control INSIDE a row (its close or overflow button,
// focused by a click or handed focus back by the action menu) belongs to
// that control: Enter there must reopen the menu, not re-select the row.
// Same contract as the flat list, which acts only on a focused row.
if (!current || current !== e.target) return;
const index = items.indexOf(current);
if (index < 0) return;
const groupId = current.dataset.tabGroupHeader || null;
const expanded = current.getAttribute('aria-expanded') === 'true';
// A group with no open rows is a leaf (no aria-expanded): nothing to open.
const expandable = current.hasAttribute('aria-expanded');
const focusAt = (next) => {
if (!next) return;
this._setTabTreeStop(container, next);
next.focus();
};
const toggle = (collapse) => {
// The toggle re-renders the rail; keep focus on this header through it.
this._tabFocusIdentity = `group:${groupId}`;
this.toggleTabGroupCollapsed(groupId, collapse);
};
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp': {
const step = e.key === 'ArrowDown' ? 1 : -1;
focusAt(items[(index + step + items.length) % items.length]);
break;
}
case 'Home':
focusAt(items[0]);
break;
case 'End':
focusAt(items[items.length - 1]);
break;
case 'ArrowRight':
if (!groupId || !expandable) return;
if (!expanded) toggle(false);
else {
const child = items.find((item) => item.closest('[role="group"]')?.id === current.getAttribute('aria-owns'));
if (!child) return;
focusAt(child);
}
break;
case 'ArrowLeft':
if (groupId) {
if (!expandable || !expanded) return;
toggle(true);
} else {
const group = current.closest('[role="group"]');
const header = group ? container.querySelector(`[aria-owns="${CSS.escape(group.id)}"]`) : null;
if (!header) return;
focusAt(header);
}
break;
case 'Enter':
case ' ':
if (groupId) toggle();
else this._activateTabRow(current);
break;
case 'F10':
case 'ContextMenu':
if (e.key === 'F10' && !e.shiftKey) return;
if (current.dataset.id) {
this.openTabRailActionMenu?.(
{ preventDefault() {}, stopPropagation() {}, currentTarget: current },
current.dataset.id
);
} else if (current.dataset.webviewId) {
this.showWebviewModal?.(current.dataset.webviewId);
} else return;
break;
default:
return;
}
e.preventDefault();
}
handleSessionTabClick(event, sessionId) {
@@ -5978,6 +6503,141 @@ class CodemanApp {
}
}
// ═══════════════════════════════════════════════════════════════
// Owner tab layout: grouped vertical rail (read-only)
// ═══════════════════════════════════════════════════════════════
//
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
// already projects them onto the global session order, so sessionOrder, Alt+N,
// Ctrl+Tab and every other order consumer are untouched here. This layer only
// decides how the VERTICAL rail draws rows: in sections, with per-device
// collapse. With no groups (or any read failure) the rail is the flat list it
// has always been.
_ensureTabLayoutCoordinator() {
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
if (!window.CodemanTabLayout) return null;
this._tabLayoutCoordinator = window.CodemanTabLayout.createLoadCoordinator({
fetchLayout: async () => {
const data = await this._apiJson('/api/tab-layout');
if (!data?.layout) throw new Error('Tab layout unavailable');
return data.layout;
},
applyLayout: (layout) => this._applyTabLayout(layout),
applyFallback: () => this._applyTabLayout(null),
// 5 s, 10 s, 20 s, 40 s, then stop until the next SSE init or
// tab:layoutChanged asks again: an unreachable server must not cost a
// fetch every 5 s for as long as the page stays open.
retryDelayMs: 5000,
maxRetryDelayMs: 60000,
maxRetries: 4,
scheduleRetry: (retry, delayMs) => setTimeout(retry, delayMs),
cancelRetry: (timer) => clearTimeout(timer),
});
return this._tabLayoutCoordinator;
}
_loadTabLayout() {
return this._ensureTabLayoutCoordinator()?.load() ?? Promise.resolve(false);
}
/** SSE tab:layoutChanged carries `{ owner, version }`; the layout itself is re-read. */
_onTabLayoutChanged(data) {
const me = window.__codemanUser;
// Another user's layout changed: nothing of ours moved. (The GET is
// owner-scoped server-side, so this is a saved request, not a guard.)
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
this._loadTabLayout();
}
/** Adopt a layout read (or null after a failed read, which renders flat). */
_applyTabLayout(layout) {
let next = null;
if (layout) {
try {
next = window.CodemanTabLayout.normalizeLayout(layout);
} catch {
next = null;
}
}
// An overtaken response is already dropped by the coordinator; this guards a
// reordering between the coordinator and an SSE-triggered reload.
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
this.tabLayout = next;
const storage = this._getTabCollapseStorage();
const collapsed = storage && next
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
: { ids: [], ok: !next };
if (!collapsed.ok) this._tabCollapseStorageFailed = true;
this.collapsedTabGroupIds = new Set(collapsed.ids);
// The server announces a layout change on every session create/close, web
// tab create/delete and order PUT, and most of those move nothing on this
// rail (always so on the flat rail, which is every owner without groups).
// Rebuild only when what the rail would draw actually changed.
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
}
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
_getTabCollapseStorage() {
if (this._tabCollapseStorageFailed) return null;
try {
return window.localStorage;
} catch {
this._tabCollapseStorageFailed = true;
return null;
}
}
/**
* The grouped projection for the CURRENT render, or null when the rail should
* render flat: horizontal strip (incl. phones and the sidebar, which force it),
* no layout yet, a failed read, or a layout without groups.
*/
_projectTabGroups() {
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return null;
return window.CodemanTabLayout.project(this.tabLayout, {
// sessionOrder, not the sessions Map: rows are built in that order, so a
// session the layout has not placed yet lands where the flat strip has it.
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
collapsedGroupIds: [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
});
}
_tabGroupStructureKey(projection) {
return window.CodemanTabLayout?.structureKey(this.tabLayout, projection, [...this.collapsedTabGroupIds]) ?? null;
}
/** True when the DOM's grouping no longer matches what a render would draw. */
_isTabGroupStructureStale(projection = this._projectTabGroups()) {
return this._tabGroupStructureKey(projection) !== this._lastTabGroupStructureKey;
}
/**
* Collapse/expand one group (header click). Per-device: stored in localStorage,
* never sent to the server, so collapsing on a laptop leaves the desktop alone.
* A storage failure leaves every group expanded rather than half-remembered.
*/
toggleTabGroupCollapsed(groupId, forceCollapsed) {
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
const next = new Set(this.collapsedTabGroupIds);
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
if (shouldCollapse) next.add(groupId);
else next.delete(groupId);
const storage = this._getTabCollapseStorage();
const saved = storage
? window.CodemanTabLayout.saveCollapsedGroupIds(storage, [...next])
: { ids: [], ok: false };
if (!saved.ok) this._tabCollapseStorageFailed = true;
this.collapsedTabGroupIds = new Set(saved.ids);
// The full render also redraws connectors anchored to rows that just moved.
this._fullRenderSessionTabs();
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
}
// Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() {
const container = this.$('sessionTabs');
@@ -5989,7 +6649,10 @@ class CodemanApp {
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
// order this list is no longer showing.
if (this.isTabRailSorted()) {
// The grouped rail is read-only for now: a flat-order drag cannot express
// "move into that group", and the server would re-rank it within its old
// group anyway. Grouped editing comes with its own drag model.
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
return;
}
@@ -6014,6 +6677,9 @@ class CodemanApp {
});
tab.addEventListener('dragover', (e) => {
// Grouped by state: a tab in another group is not a drop target, and
// leaving the event alone (no preventDefault) is what shows "no drop".
if (this._isTabDropAcrossTriageGroups(tab)) return;
e.preventDefault();
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
@@ -6043,6 +6709,7 @@ class CodemanApp {
tab.classList.remove('drag-over-left', 'drag-over-right');
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
if (this._isTabDropAcrossTriageGroups(tab)) return;
const targetId = tab.dataset.id;
const draggedId = this.draggedTabId;
@@ -6080,10 +6747,26 @@ class CodemanApp {
});
}
/**
* Grouped rail: Ctrl+Shift+{ / } may only swap the active session with a
* neighbour in its OWN section. Across a group boundary the server re-ranks
* each group on its own (`putLegacyOrder`), so nothing moves there, no
* session:orderChanged comes back, and this client would keep a swapped
* sessionOrder (and Alt+N targets) that no other device shares. Same reason
* drag is off in the grouped rail. Any other layout: always allowed.
*/
_canSwapActiveTabWith(neighbourId) {
const projection = this._projectTabGroups();
if (!projection) return true;
const sectionOf = (id) => projection.sectionByRef[`session:${id}`];
return sectionOf(this.activeSessionId) === sectionOf(neighbourId);
}
moveActiveTabLeft() {
if (!this.activeSessionId) return;
const idx = this.sessionOrder.indexOf(this.activeSessionId);
if (idx <= 0) return;
if (!this._canSwapActiveTabWith(this.sessionOrder[idx - 1])) return;
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
this.saveSessionOrder();
this._fullRenderSessionTabs();
@@ -6093,6 +6776,7 @@ class CodemanApp {
if (!this.activeSessionId) return;
const idx = this.sessionOrder.indexOf(this.activeSessionId);
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
if (!this._canSwapActiveTabWith(this.sessionOrder[idx + 1])) return;
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
this.saveSessionOrder();
this._fullRenderSessionTabs();
+123
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/ +
@@ -983,6 +1100,12 @@ 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,
+2
View File
@@ -67,6 +67,7 @@
'Open away digest': '打开离开期间摘要',
'Session Manager': '会话管理器',
'Session actions': '会话操作',
Ungrouped: '未分组',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
@@ -202,6 +203,7 @@
Running: '运行中',
Idle: '空闲',
Working: '工作中',
Waiting: '等待中',
Today: '今天',
Home: '主页',
Local: '本地',
+135 -2
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>
@@ -1822,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 ═══════════════════════════════════════ -->
@@ -1882,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>
@@ -2024,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>
@@ -2037,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>
@@ -2172,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>
@@ -2474,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 ════════════════════════════════════════════ -->
@@ -2601,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 ════════════════════════════════════════════════════ -->
@@ -3738,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);
+8
View File
@@ -755,6 +755,14 @@ html.mobile-init .file-browser-panel {
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. */
+26
View File
@@ -6028,6 +6028,11 @@ Object.assign(CodemanApp.prototype, {
}
}
// Sparklines for the Compact header style. Always recorded (four numbers a
// poll), so switching styles shows history at once instead of empty bars.
this._pushStatSpark('cpu', stats.cpu);
this._pushStatSpark('mem', stats.memory?.percent);
if (memEl && memBar) {
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
memEl.textContent = `${memGB}G`;
@@ -6045,6 +6050,27 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Record one percentage sample and redraw that stat's sparkline: one bar per
* sample, newest on the right, as many samples as the template has bars.
* Bars stay 10% tall at minimum so an idle machine still draws a baseline.
*/
_pushStatSpark(key, percent) {
const history = (this._statSparkHistory ||= { cpu: [], mem: [] })[key];
if (!history) return;
const el = this.$(key === 'cpu' ? 'statCpuSpark' : 'statMemSpark');
const size = el?.children.length || 4;
const value = Number(percent);
history.push(Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0);
while (history.length > size) history.shift();
if (!el) return;
const offset = size - history.length;
for (let i = 0; i < el.children.length; i++) {
const sample = history[i - offset];
el.children[i].style.height = sample === undefined ? '' : `${Math.max(10, Math.round(sample))}%`;
}
},
// ─── Clipboard ──────────────────────────────────────────────────────────────
async _onClipboardWrite(data) {
+2
View File
@@ -1038,6 +1038,7 @@ Object.assign(CodemanApp.prototype, {
const ralphGlobalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings);
const effort = this.getEffortSetting(ralphGlobalSettings);
const advisorModel = this.getAdvisorSetting(ralphGlobalSettings);
const res = await fetch('/api/ralph-loop/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1050,6 +1051,7 @@ Object.assign(CodemanApp.prototype, {
planItems: enabledItems?.length ? enabledItems : undefined,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
}),
});
const data = await res.json();
+27 -11
View File
@@ -153,7 +153,6 @@ Object.assign(CodemanApp.prototype, {
const edges = this._collectLineageEdges();
if (edges.length === 0) return;
this._lineageEdgeCount = edges.length;
if (!rects) rects = new Map();
// PHASE 1 — reads.
@@ -162,19 +161,35 @@ Object.assign(CodemanApp.prototype, {
const stripRect = strip.getBoundingClientRect();
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
// A session hidden inside a collapsed group of the grouped rail has no row
// to anchor to, so its end of the arc moves to that group's header (a
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
// header would be an arc from a row to itself: skipped.
const resolveEndpoint = (id) => {
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
const groupId = this._hiddenTabGroupByRef?.get('session:' + id);
if (!groupId) return { key: 'tab:' + id, element: null, proxied: false };
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
return { key: 'group:' + groupId, element: header, proxied: !!header };
};
const resolvedEdges = [];
for (const edge of edges) {
for (const id of [edge.parentId, edge.childId]) {
const key = 'tab:' + id;
if (rects.has(key)) continue;
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
rects.set(key, tab ? tab.getBoundingClientRect() : null);
const parentEndpoint = resolveEndpoint(edge.parentId);
const childEndpoint = resolveEndpoint(edge.childId);
if (parentEndpoint.key === childEndpoint.key) continue;
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
for (const endpoint of [parentEndpoint, childEndpoint]) {
if (rects.has(endpoint.key)) continue;
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
}
}
this._lineageEdgeCount = resolvedEdges.length;
// PHASE 2 — writes, from the cache only.
for (const edge of edges) {
const parentRect = rects.get('tab:' + edge.parentId);
const childRect = rects.get('tab:' + edge.childId);
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
const parentRect = rects.get(parentEndpoint.key);
const childRect = rects.get(childEndpoint.key);
if (!parentRect || !childRect) continue;
const geom = compute({
@@ -191,7 +206,8 @@ Object.assign(CodemanApp.prototype, {
// The working class marches the dashes, so an active worker is visible along
// the line itself. `status` is the CHILD's, which is the interesting end.
const working = edge.status === 'working' ? ' lineage-line--working' : '';
line.setAttribute('class', 'connection-line lineage-line' + working);
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
// Every arc out of one tab shares it — see _lineageColorFor().
@@ -211,7 +227,7 @@ Object.assign(CodemanApp.prototype, {
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '3.5');
dot.setAttribute('class', 'lineage-line-dot' + working);
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
svg.appendChild(dot);
+13
View File
@@ -169,6 +169,17 @@ Object.assign(CodemanApp.prototype, {
return valid.includes(effort) ? effort : undefined;
},
/**
* Resolve the advisor model for new Claude sessions from global settings.
* Returns 'fable' | 'opus' | 'sonnet', or undefined (= leave it to the CLI's own
* /advisor choice). Sent as the `advisorModel` payload field; the backend merges it
* into the launch's `claude --settings` JSON, so /advisor still switches it in-session.
*/
getAdvisorSetting(globalSettings) {
const advisor = globalSettings?.claudeAdvisorModel;
return ['fable', 'opus', 'sonnet'].includes(advisor) ? advisor : undefined;
},
// ═══════════════════════════════════════════════════════════════
// Quick Start
// ═══════════════════════════════════════════════════════════════
@@ -1965,6 +1976,7 @@ Object.assign(CodemanApp.prototype, {
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// Explicit Claude Model choice (App Settings) wins over the legacy 1M Opus
// toggles; both flow as `modelOverride` → the case's .claude/settings.local.json
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
@@ -1980,6 +1992,7 @@ Object.assign(CodemanApp.prototype, {
workingDir, name,
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}),
})
}).then(r => r.json())
+356 -11
View File
@@ -382,6 +382,7 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
@@ -416,6 +417,13 @@ Object.assign(CodemanApp.prototype, {
// .checked fires no onchange, so the list's visibility (and lazy load)
// needs an explicit sync on every open, not just a save.
this.applyCliManagementVisibility();
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
// The routes read the SAVED setting, so remember what it was on open: switching it on
// here does nothing server-side until Save (see mcpSync()).
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
this.applyMcpSyncVisibility();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
@@ -490,6 +498,8 @@ Object.assign(CodemanApp.prototype, {
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
document.getElementById('appSettingsTabRailSort').value =
settings.tabRailSort ?? defaults.tabRailSort ?? 'activity';
document.getElementById('appSettingsTabGrouping').value =
(settings.tabGrouping ?? defaults.tabGrouping ?? 'state') === 'none' ? 'none' : 'state';
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
@@ -527,6 +537,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
document.getElementById('appSettingsClaudeAdvisor').value = settings.claudeAdvisorModel ?? '';
// CPU Priority settings
const niceSettings = settings.nice || {};
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
@@ -627,6 +638,7 @@ Object.assign(CodemanApp.prototype, {
this._syncSettingsChips();
this._syncModelCards();
this._syncEffortSegment();
this._syncAdvisorSegment();
// Back to the top of the document (one scroll, not a tab reset). Updates is
// first now: the version this install is running, and whether a newer one is
// waiting, are the two things worth seeing before any preference. The rest of
@@ -718,6 +730,7 @@ Object.assign(CodemanApp.prototype, {
if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return;
this._buildModelCards();
this._buildEffortSegment();
this._buildAdvisorSegment();
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail
// after the first open, and the menu must not drift from the rail.
this._buildSettingsJumpMenu();
@@ -993,8 +1006,28 @@ Object.assign(CodemanApp.prototype, {
},
_buildEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
this._buildSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment');
},
_syncEffortSegment() {
this._syncSelectSegment('appSettingsThinkingEffort', 'appSettingsEffortSegment');
},
_buildAdvisorSegment() {
this._buildSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment');
},
_syncAdvisorSegment() {
this._syncSelectSegment('appSettingsClaudeAdvisor', 'appSettingsAdvisorSegment');
},
/**
* Build a radio segment as a view over a hidden <select>, which stays the single
* source of truth for load/save (the same contract as the model cards).
*/
_buildSelectSegment(selectId, segId) {
const select = document.getElementById(selectId);
const seg = document.getElementById(segId);
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
seg.innerHTML = '';
[...select.options].forEach(opt => {
@@ -1005,16 +1038,16 @@ Object.assign(CodemanApp.prototype, {
btn.textContent = opt.textContent;
btn.addEventListener('click', () => {
select.value = opt.value;
this._syncEffortSegment();
this._syncSelectSegment(selectId, segId);
});
seg.appendChild(btn);
});
seg.dataset.built = '1';
},
_syncEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
_syncSelectSegment(selectId, segId) {
const select = document.getElementById(selectId);
const seg = document.getElementById(segId);
if (!select || !seg) return;
seg.querySelectorAll('button').forEach(btn => {
const on = btn.dataset.value === (select.value || '');
@@ -1114,6 +1147,226 @@ Object.assign(CodemanApp.prototype, {
this._updateCheck = null;
},
/**
* Settings → Terminal & Input → Key tester: prints what the browser reports for each key event.
* Read-only and local; it never reaches a session. keypress is shown on purpose: that event is
* why a Shift-only Enter used to submit (xterm drops Ctrl/Alt keypresses, not Shift ones).
*/
keyTesterEvent(ev) {
const log = document.getElementById('keyTesterLog');
if (!log) return;
// Never preventDefault on keydown: that suppresses the keypress this panel exists to show.
// The field is readonly, so nothing is typed into it either way.
const mods = ['ctrlKey', 'shiftKey', 'altKey', 'metaKey'].filter((m) => ev[m]).map((m) => m.replace('Key', ''));
const line =
`${ev.type.padEnd(8)} key=${JSON.stringify(ev.key)} code=${ev.code || '-'} ` +
`mods=${mods.join('+') || 'none'}` +
(ev.type === 'keypress' ? ` charCode=${ev.charCode}` : '') +
(ev.repeat ? ' (repeat)' : '');
const lines = (log.textContent ? log.textContent.split('\n') : []).concat(line);
log.textContent = lines.slice(-14).join('\n');
log.style.display = 'block';
},
/**
* MCP sync is opt-in (`mcpSyncEnabled`): with the flag off the action row is hidden rather than
* shown disabled, because both endpoints would only answer 403. Called on open and from the
* checkbox's own onchange (assigning .checked fires no change event).
*/
applyMcpSyncVisibility() {
const on = document.getElementById('appSettingsMcpSync')?.checked ?? false;
const row = document.getElementById('mcpSyncActionRow');
if (row) row.style.display = on ? '' : 'none';
const out = this.$('mcpSyncResult');
if (!on && out) { out.style.display = 'none'; out.innerHTML = ''; }
this._applyMcpSyncAdminGate();
},
/**
* Both /api/mcp-sync verbs are admin-only in multi-user mode (they write files in the server
* user's home), so a non-admin gets no MCP group at all, switch included, the same way
* _applyCliManagementAdminGate hides the CLI list. Also wired to `codeman:me`, because
* `window.__codemanUser`'s real role can resolve after settings were opened once.
*/
_applyMcpSyncAdminGate() {
const group = document.getElementById('mcpSyncGroup');
if (!group) return;
const me = window.__codemanUser || {};
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
const show = (html) => {
if (out) { out.style.display = 'block'; out.innerHTML = html; }
};
// Switched on in this modal but not saved yet: the routes would only answer "disabled".
if (!this._mcpSyncSavedOn) {
show('Save settings to turn MCP sync on first, then reopen Settings to preview or sync.');
return;
}
if (apply && !confirm('Add missing MCP servers to every installed, enabled CLI\'s config file? Env values and headers on those servers are copied too.')) return;
show('Working…');
const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync');
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
show(escapeHtml(body?.error || 'MCP sync failed.'));
return;
}
const data = body.data;
const rows = data.targets.map((t) => {
if (t.status === 'absent') return `<li><b>${escapeHtml(t.label)}</b>: not installed, skipped</li>`;
if (t.status === 'skipped') return `<li><b>${escapeHtml(t.label)}</b>: not touched (${escapeHtml(t.error || 'config location unknown')})</li>`;
if (t.status === 'unreadable') return `<li><b>${escapeHtml(t.label)}</b>: not touched, file can't be read safely (${escapeHtml(t.error || 'unreadable')})</li>`;
if (t.status === 'failed') return `<li><b>${escapeHtml(t.label)}</b>: failed (${escapeHtml(t.error || 'error')}); the file may be unchanged</li>`;
const verb = data.applied ? 'added' : 'would add';
const parts = [t.added.length ? `${verb} ${t.added.map(escapeHtml).join(', ')}` : 'up to date'];
if (t.skipped.length) parts.push(`can't express ${t.skipped.map(escapeHtml).join(', ')}`);
const count = `${t.servers.length} server${t.servers.length === 1 ? '' : 's'}`;
return `<li><b>${escapeHtml(t.label)}</b> (${count}): ${parts.join('; ')}</li>`;
});
const conflicts = data.conflicts.length
? `<p>Defined differently across CLIs (each existing definition is kept; the first CLI's is copied where the name is missing): ${data.conflicts.map(escapeHtml).join(', ')}</p>`
: '';
const disabled = data.disabled?.length
? `<p>Switched off in their own CLI, so not copied: ${data.disabled.map(escapeHtml).join(', ')}</p>`
: '';
const unsupported = data.unsupported?.length
? `<p>No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}</p>`
: '';
show(`<ul>${rows.join('')}</ul>${conflicts}${disabled}${unsupported}`);
},
/**
* Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a
* settings-payload field: the URL is a secret, so it never round-trips through settings.json or
* this page. The URL box is write-only; the status line shows scheme + host only.
*
* Three ways to save, one PUT: the group's own Save, Send test (saves pending edits first, so it
* never tests the old URL while the box shows a new one), and the modal's main Save, which calls
* saveWebhook() beside the settings PUT the same way it saves the model config
* (saveModelConfigFromSettings). `_webhookLoaded` is what loadWebhook() put on screen, so
* `_webhookPending()` can tell an edited group from an untouched one.
*/
_webhookSay(text, bad = false) {
const out = document.getElementById('webhookResult');
if (!out) return;
out.textContent = text;
out.style.display = text ? 'block' : 'none';
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
},
async loadWebhook() {
const group = document.getElementById('webhookGroup');
if (!group) return;
const res = await this._api('/api/webhook');
if (!res || !res.ok) {
this._webhookLoaded = null;
group.style.display = 'none'; // not an admin in multi-user mode, or the server predates the route
return;
}
let body = null;
try { body = await res.json(); } catch { /* leave hidden */ }
if (!body || body.success === false) { this._webhookLoaded = null; group.style.display = 'none'; return; }
const d = body.data;
group.style.display = '';
document.getElementById('webhookEnabled').checked = d.enabled === true;
document.getElementById('webhookKind').value = d.kind;
document.getElementById('webhookScope').value = d.scope;
const url = document.getElementById('webhookUrl');
url.value = '';
url.placeholder = d.hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic';
document.getElementById('webhookUrlHint').textContent = d.hasUrl ? `Saved: ${d.urlMasked}` : 'Nothing saved yet.';
const clearBtn = document.getElementById('webhookClearBtn');
if (clearBtn) clearBtn.style.display = d.hasUrl ? '' : 'none';
// Read back from the controls, so a value the <select> does not offer compares as what is shown.
this._webhookLoaded = {
enabled: document.getElementById('webhookEnabled').checked,
kind: document.getElementById('webhookKind').value,
scope: document.getElementById('webhookScope').value,
};
if (d.lastResult) {
const when = new Date(d.lastResult.at).toLocaleString();
this._webhookSay(
d.lastResult.ok ? `Last delivery succeeded (${when}).` : `Last delivery failed (${when}): ${d.lastResult.error}`,
!d.lastResult.ok
);
} else {
this._webhookSay('');
}
},
/** True when the visible webhook group differs from what loadWebhook() last showed. */
_webhookPending() {
const group = document.getElementById('webhookGroup');
const loaded = this._webhookLoaded;
if (!group || group.style.display === 'none' || !loaded) return false;
return (
document.getElementById('webhookUrl').value.trim() !== '' ||
document.getElementById('webhookEnabled').checked !== loaded.enabled ||
document.getElementById('webhookKind').value !== loaded.kind ||
document.getElementById('webhookScope').value !== loaded.scope
);
},
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
async saveWebhook() {
const payload = {
enabled: document.getElementById('webhookEnabled').checked,
kind: document.getElementById('webhookKind').value,
scope: document.getElementById('webhookScope').value,
};
const url = document.getElementById('webhookUrl').value.trim();
if (url) payload.url = url; // blank = keep the saved one (Remove URL is the way to clear it)
const res = await this._api('/api/webhook', { method: 'PUT', body: payload });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
const error = body?.error || 'Could not save the webhook.';
this._webhookSay(error, true);
return error;
}
await this.loadWebhook();
this._webhookSay('Saved.');
return '';
},
/** Delete the saved URL from the server (the API clears on `url: ""`), which also turns the channel off. */
async clearWebhook() {
if (!confirm('Remove the saved webhook URL from the server? Webhook alerts stop until you save a new one.')) return;
const res = await this._api('/api/webhook', { method: 'PUT', body: { url: '', enabled: false } });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not remove the webhook URL.', true);
return;
}
await this.loadWebhook();
this._webhookSay('Webhook URL removed.');
},
async testWebhook() {
const btn = document.getElementById('webhookTestBtn');
if (btn) btn.disabled = true;
try {
if (this._webhookPending() && (await this.saveWebhook())) return; // the save's error is already shown
this._webhookSay('Sending…');
const res = await this._apiPost('/api/webhook/test', {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not send the test.', true);
return;
}
const r = body.data;
this._webhookSay(r.ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${r.error}`, !r.ok);
} finally {
if (btn) btn.disabled = false;
}
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
@@ -2147,6 +2400,7 @@ Object.assign(CodemanApp.prototype, {
// Header visibility settings
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
@@ -2159,6 +2413,7 @@ Object.assign(CodemanApp.prototype, {
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
@@ -2193,6 +2448,7 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
tabRailSort: document.getElementById('appSettingsTabRailSort').value,
tabGrouping: document.getElementById('appSettingsTabGrouping').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -2214,6 +2470,7 @@ Object.assign(CodemanApp.prototype, {
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
claudeAdvisorModel: document.getElementById('appSettingsClaudeAdvisor').value,
// CPU Priority settings
nice: {
enabled: document.getElementById('appSettingsNiceEnabled').checked,
@@ -2431,6 +2688,7 @@ Object.assign(CodemanApp.prototype, {
sessionLineageLines: _sll,
...serverSettings
} = settings;
let webhookError = '';
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
@@ -2455,7 +2713,16 @@ Object.assign(CodemanApp.prototype, {
// Save model configuration separately
await this.saveModelConfigFromSettings();
this.showToast('Settings saved', 'success');
// The webhook is server state in its own 0600 file (its URL is a secret, kept out of
// settings.json), so like the model config above it is saved beside the settings PUT, not in
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
if (webhookError) {
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
} else {
this.showToast('Settings saved', 'success');
}
// Show tunnel-specific feedback if toggled on
if (settings.tunnelEnabled) {
@@ -2466,7 +2733,11 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Settings saved locally', 'warning');
}
this.closeAppSettings();
if (webhookError) {
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
} else {
this.closeAppSettings();
}
// Voice availability is a server-side answer, so re-probe after a save:
// otherwise the mic keeps using the pre-save provider until the next reload.
@@ -3099,6 +3370,7 @@ Object.assign(CodemanApp.prototype, {
tabRailWidth: 256,
tabRailDetail: 'rich',
tabRailSort: 'activity',
tabGrouping: 'state',
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
cjkInputEnabled: false,
@@ -3200,6 +3472,63 @@ Object.assign(CodemanApp.prototype, {
return now === before ? undefined : now;
},
/**
* The stored header-stats style, or the default. Anything but the three
* known values (an absent key, a value from a newer build) reads as 'tiles',
* the default (the tile variant of Discussion #426's option G).
*/
resolveHeaderStatsStyle(settings) {
const value = settings?.headerStatsStyle ?? this.getDefaultSettings().headerStatsStyle;
return value === 'classic' || value === 'compact' ? value : 'tiles';
},
/**
* Apply a header-stats style: the `data-header-stats` attribute every rule in
* the "Header stats styles" block of styles.css keys on, plus the two DOM
* moves the clustered styles need.
*
* The template keeps the classic order, where the connection indicator sits
* before the font controls and the plan-usage chip near the end of the header.
* Compact and Tiles draw them as ONE cluster (WS · CPU · MEM, then the plan
* windows), so the indicator moves into #headerSystemStats as its first child
* and the chip moves right after it. Comment anchors left at the template
* positions are what 'classic' moves them back to, so switching back restores
* the header exactly.
*
* ⚠️ The indicator only joins the pill while System Stats is shown: the pill
* is hidden with `display: none`, and the WS readout must not disappear with
* it. Both elements keep their ids, so every writer (setConnectionStatus,
* updatePlanUsageChip) finds them wherever they sit.
*
* @param {{style: 'classic'|'compact'|'tiles', showSystemStats: boolean}} opts
*/
applyHeaderStatsStyle({ style, showSystemStats }) {
document.documentElement.dataset.headerStats = style;
const stats = document.getElementById('headerSystemStats');
const conn = document.getElementById('connectionIndicator');
const plan = document.getElementById('planUsageChip');
if (!stats || !conn || !plan) return;
if (!this._headerStatsAnchors) {
const connAnchor = document.createComment(' connection indicator (classic position) ');
const planAnchor = document.createComment(' plan usage chip (classic position) ');
conn.before(connAnchor);
plan.before(planAnchor);
this._headerStatsAnchors = { conn: connAnchor, plan: planAnchor };
}
const anchors = this._headerStatsAnchors;
const clustered = style !== 'classic';
if (clustered && showSystemStats) {
if (stats.firstElementChild !== conn) stats.prepend(conn);
} else if (anchors.conn.nextSibling !== conn) {
anchors.conn.after(conn);
}
if (clustered) {
if (stats.nextElementSibling !== plan) stats.after(plan);
} else if (anchors.plan.nextSibling !== plan) {
anchors.plan.after(plan);
}
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -3230,6 +3559,12 @@ Object.assign(CodemanApp.prototype, {
if (tokenCountEl) {
tokenCountEl.style.display = showTokenCount ? '' : 'none';
}
// After the System Stats visibility above: whether WS joins the stats pill
// depends on the pill being shown.
this.applyHeaderStatsStyle({
style: compactHeader || this.isSoloWindow ? 'classic' : this.resolveHeaderStatsStyle(settings),
showSystemStats,
});
// Hide lifecycle log button when setting is disabled
// Default OFF: the lifecycle-log document icon is opt-in; the default header
@@ -3393,6 +3728,15 @@ Object.assign(CodemanApp.prototype, {
const sort = (settings.tabRailSort ?? defaults.tabRailSort ?? 'activity') === 'manual' ? 'manual' : 'activity';
root.dataset.tabRailSort = sort;
// Grouping rides on a fourth attribute, for the same reason: it is applied
// as inline `order` plus heading elements the render paths emit, so a flip
// has to re-render, and `isTabTriage()` (app.js) reads one attribute per
// pass instead of re-parsing localStorage. Anything but an explicit 'none'
// is 'state', the default (Discussion #426, option C).
const previousGrouping = root.dataset.tabGrouping || 'state';
const grouping = (settings.tabGrouping ?? defaults.tabGrouping ?? 'state') === 'none' ? 'none' : 'state';
root.dataset.tabGrouping = grouping;
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
const headerHost = document.getElementById('sessionTabsHost');
@@ -3416,7 +3760,7 @@ Object.assign(CodemanApp.prototype, {
// the row template, not toggled by CSS — same reasoning as the sidebar's
// detail half in applySessionListLayout(). Taller rows also move every
// connector anchored to a tab rect.
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousGrouping !== grouping;
if (orientationChanged) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.syncTerminalGeometry?.();
@@ -3686,10 +4030,10 @@ Object.assign(CodemanApp.prototype, {
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
// are NOT display keys — they control server-side behavior and must sync from server.
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabGrouping', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
@@ -4106,4 +4450,5 @@ Object.assign(CodemanApp.prototype, {
document.addEventListener?.('codeman:me', () => {
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
});
+518 -67
View File
File diff suppressed because one or more lines are too long
+354
View File
@@ -0,0 +1,354 @@
/**
* @fileoverview Read-only browser projection of the owner tab layout.
*
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
* is the server model). Browser assets cannot import that TypeScript, so this
* module is a small, dependency-free mirror that owns three things:
*
* 1. Projection: which live sessions and open web tabs land in which group,
* and which rows a collapsed group hides.
* 2. Rendering: the grouped markup for the vertical tab rail. Rows themselves
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
* byte-identical to the flat rail's row.
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a capped, backed-off retry.
*
* The server stays the only authority for layout content. Collapse is a
* per-device view preference and lives in localStorage only.
*
* Grouped rendering is opt-in by construction: a layout with no groups (every
* owner until they create one) projects to `null`, and the caller keeps the flat
* rail exactly as it was.
*
* @dependency none
* @loadorder 5.9 (before app.js, which reads window.CodemanTabLayout)
*/
(function initCodemanTabLayout(global) {
'use strict';
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
const refKey = (ref) => `${ref.kind}:${ref.id}`;
const validRef = (ref) =>
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
const stableIds = (value) => [...new Set(asIds(value))];
const copyRefs = (value) =>
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
/**
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
* server adding model fields cannot leak half-understood state into the view.
*/
function normalizeLayout(value) {
if (!value || typeof value !== 'object') throw new Error('Invalid tab layout');
const groups = Array.isArray(value.groups) ? value.groups : [];
return {
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
groups: groups
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
.map((group) => ({
id: group.id,
name: typeof group.name === 'string' ? group.name : '',
refs: copyRefs(group.refs),
})),
ungrouped: copyRefs(value.ungrouped),
};
}
function hasGroups(layout) {
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
}
/** Stored collapse ids, or null when the stored value is not a JSON array. */
function parseCollapsedIds(raw) {
if (raw === null) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? stableIds(parsed) : null;
} catch (_error) {
return null;
}
}
/**
* Read the per-device collapse ids. `ok: false` means the STORE failed (a read
* or write threw), and the caller then keeps every group expanded. A malformed
* VALUE is not a store failure: it reads as "nothing collapsed" and is
* rewritten, or a shape left behind by another build (a rollback) would leave
* collapse disabled on this device for good.
*/
function loadCollapsedGroupIds(storage, validGroupIds) {
try {
const parsed = parseCollapsedIds(storage.getItem(COLLAPSED_STORAGE_KEY));
const loaded = parsed || [];
if (validGroupIds === undefined) return { ids: loaded, ok: true };
// Garbage-collect ids of groups that no longer exist, so a deleted group's
// id cannot silently collapse a future group that reuses it.
const valid = new Set(stableIds(validGroupIds));
const kept = loaded.filter((id) => valid.has(id));
if (!parsed || kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
return { ids: kept, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
function saveCollapsedGroupIds(storage, groupIds) {
const ids = stableIds(groupIds);
try {
storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(ids));
return { ids, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
/**
* Project a layout onto what is live in this browser.
*
* Every live session and open web tab appears exactly once: stored refs keep
* their group and stored order; anything the layout has not caught up with yet
* (a session created a moment ago, a web tab opened on this device only) is
* appended to the ungrouped section in the caller's order. Saved web tabs that
* are not open here are skipped, as are refs to sessions that are gone.
*
* A collapsed group hides its rows, EXCEPT the highlighted one (the active web
* tab, else the active session), so selecting a hidden session by keyboard,
* palette or Alt+N never leaves the user with no visible selection.
*
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
* null when the layout has no groups: the caller renders the flat rail
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
* collapse hides (`hidden`); `sectionByRef` maps every placed row
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
*/
function project(layoutInput, options = {}) {
if (!layoutInput) return null;
const layout = normalizeLayout(layoutInput);
if (!hasGroups(layout)) return null;
const liveSessionIds = stableIds(options.liveSessionIds);
const openWebviewIds = stableIds(options.openWebviewIds);
const live = new Set(liveSessionIds);
const open = new Set(openWebviewIds);
const collapsed = new Set(asIds(options.collapsedGroupIds));
const highlighted = options.activeWebviewId
? `webview:${options.activeWebviewId}`
: options.activeSessionId
? `session:${options.activeSessionId}`
: '';
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
const placed = new Set();
const visibleRefs = [];
const hiddenTabGroupByRef = {};
const sectionByRef = {};
const sections = [];
const place = (refs, sectionId, isCollapsed) => {
const shown = [];
const hidden = [];
let count = 0;
for (const ref of refs) {
const key = refKey(ref);
if (placed.has(key) || !renderable(ref)) continue;
placed.add(key);
sectionByRef[key] = sectionId;
count++;
const copy = { kind: ref.kind, id: ref.id };
if (isCollapsed && key !== highlighted) {
hiddenTabGroupByRef[key] = sectionId;
hidden.push(copy);
continue;
}
shown.push(copy);
visibleRefs.push(copy);
}
return { shown, hidden, count };
};
for (const group of layout.groups) {
const isCollapsed = collapsed.has(group.id);
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
}
const omissions = [
...liveSessionIds.map((id) => ({ kind: 'session', id })),
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
];
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
if (ungrouped.count > 0) {
sections.push({
id: null,
name: '',
refs: ungrouped.shown,
hidden: [],
count: ungrouped.count,
collapsed: false,
});
}
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
}
const ALERT_RANK = { action: 2, idle: 1 };
/**
* The most urgent alert behind each COLLAPSED header: `{ [groupId]: 'action' |
* 'idle' }` over the session rows the collapse hides. A shown row (the kept
* selection, any expanded group) draws its own alert, so it is not counted
* here. `alertOf(sessionId)` is the caller's tab alert lookup.
*/
function hiddenGroupAlerts(projection, alertOf) {
const result = {};
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
for (const section of sections) {
if (section.id === null || !Array.isArray(section.hidden)) continue;
let best = null;
for (const ref of section.hidden) {
if (ref.kind !== 'session') continue;
const alert = alertOf(ref.id);
if (ALERT_RANK[alert] && (!best || ALERT_RANK[alert] > ALERT_RANK[best])) best = alert;
}
if (best) result[section.id] = best;
}
return result;
}
/**
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
* where, the headers' names, what a collapse hides), as opposed to a row's own
* status/name/badges. The incremental render path only patches rows in place,
* so a change here forces a full rebuild.
*
* Deliberately NOT the layout version: the server bumps it on every session
* create/close and order PUT, and a bump that moves nothing visible must not
* cost every client a full tab-strip rebuild. `layout` is accepted for
* signature stability only.
*/
function structureKey(_layout, projection, collapsedGroupIds) {
if (!projection) return null;
return JSON.stringify({
collapsed: stableIds(collapsedGroupIds).sort(),
sections: projection.sections.map((section) => [
section.id,
section.name,
section.count,
section.refs.map(refKey),
(section.hidden || []).map(refKey),
]),
});
}
/**
* Grouped rail markup. `renderRef(ref)` returns one row's HTML ('' to skip it);
* `escapeHtml` is the caller's escaper. Group names are user content, so they
* are escaped and marked `data-i18n-skip`.
*
* The caller makes the list itself the `tree` and marks rows up as treeitems
* (app.js `_applyTabTreeSemantics`); this markup supplies the structure:
* - a named group's header is a level-1 `treeitem` carrying `aria-expanded`.
* Its rows are a sibling `group`, so the header OWNS it via `aria-owns`
* (the rows sit below the header visually, not inside it).
* - a COLLAPSED group owns nothing: the one row it still shows (the
* selection) is a level-1 sibling, never the child of a closed node.
* - a group with NO open rows is a leaf: no `aria-expanded`, no owned group,
* so it is not announced as an expanded parent of an empty group.
* - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual
* divider only, hidden from assistive tech, and its rows are not a group.
*/
function renderProjection(projection, renderRef, escapeHtml) {
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
return sections
.map((section, index) => {
const rows = section.refs.map((ref) => renderRef(ref)).join('');
if (section.id === null) {
return (
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
`<div class="tab-layout-group-header tab-layout-ungrouped-header" aria-hidden="true"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
);
}
const id = escapeHtml(section.id);
const refsId = `tab-layout-group-refs-${index}`;
const nameId = `tab-layout-group-name-${index}`;
const leaf = section.count === 0;
const expanded = !section.collapsed && !leaf;
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
return (
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
`<span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
);
})
.join('');
}
/**
* Newest-wins layout loading. A response that was overtaken by a later load is
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
* retry, replacing any retry already pending.
*
* Retries back off and stop: the delay doubles from `retryDelayMs` up to
* `maxRetryDelayMs`, and after `maxRetries` consecutive failures nothing more
* is scheduled (`scheduleRetry(fn, delayMs)`). The next outside load (an SSE
* reconnect re-runs init, a `tab:layoutChanged` re-reads) tries again, and
* any success resets the count.
*/
function createLoadCoordinator(options) {
const baseDelay = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 5000;
const maxDelay = Number.isFinite(options.maxRetryDelayMs) ? options.maxRetryDelayMs : 60000;
const maxRetries = Number.isSafeInteger(options.maxRetries) ? options.maxRetries : 4;
let generation = 0;
let disposed = false;
let retryHandle = null;
let failures = 0;
const clearRetry = () => {
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
retryHandle = null;
};
const load = async () => {
if (disposed) return false;
const requestGeneration = ++generation;
clearRetry();
try {
const layout = await options.fetchLayout();
if (disposed || requestGeneration !== generation) return false;
failures = 0;
options.applyLayout(layout);
return true;
} catch (_error) {
if (disposed || requestGeneration !== generation) return false;
failures++;
options.applyFallback();
if (failures <= maxRetries) {
const delay = Math.min(maxDelay, baseDelay * 2 ** (failures - 1));
retryHandle = options.scheduleRetry(() => load(), delay);
}
return false;
}
};
return {
load,
dispose() {
disposed = true;
generation++;
clearRetry();
},
};
}
global.CodemanTabLayout = {
normalizeLayout,
hasGroups,
project,
hiddenGroupAlerts,
structureKey,
renderProjection,
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,
};
})(typeof window !== 'undefined' ? window : globalThis);
+82 -34
View File
@@ -77,11 +77,14 @@
this._bufferLoading = false;
this._bufferRefreshPending = false;
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
// `_liveQueue` is non-null exactly while a pull is replaying: live frames
// are held there with their arrival time instead of written under it.
// `_liveQueue` is non-null from the pull's response until its finally
// block: live frames are held there with their arrival time instead of
// written under the replay. `_markerOwed` is the "disconnected" marker a
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
this._historyPullAt = 0;
this._historyPullUseless = false;
this._liveQueue = null;
this._markerOwed = false;
this._onWheel = null;
}
@@ -169,14 +172,17 @@
// session (this.sessionId), never the primary pane's
// activeSessionId, and has no local-echo overlay of its own to flush
// first (Pane B is deliberately plainer — see the fileoverview).
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
}
return false;
}
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
@@ -301,18 +307,37 @@
}
// The socket's close, split out of connect() so the tests can drive it.
// While a history pull is running the marker waits for the pull's finally
// block: written now, it would sit above the output the pull is still
// holding (flushed after it on a skip, a downgrade or a failed fetch) or
// land in the middle of a chunked replay.
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
// written now, it would sit above the output a pull is still holding (flushed
// after it on a skip, a downgrade or a failed fetch), above a refresh's
// replay, or in the middle of a chunked replay. A pull still waiting for its
// response holds the marker too, for as long as the request takes (up to its
// budget, see _pullHistory()).
_onSocketClosed() {
this._wsReady = false;
this._wsClosed = true;
if (!this._liveQueue) this._writeDisconnectedMarker();
if (this._bufferLoading) this._markerOwed = true;
else this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a history pull that ends on a
// closed socket can write it (see _pullHistory()'s finally block).
// Settles a marker the pane owes: set when a close lands during a load (the
// replay would otherwise sit below it) or when a load wipes the terminal on
// a closed socket. Called from each load's own finally, just before
// _endBufferLoad() starts any trailing refresh.
_stampMarkerIfOwed() {
// A trailing refresh is about to clear() synchronously, while xterm parses
// a write() on a later tick: a marker written here would land in the
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
// The refresh re-owes the marker on a closed socket and stamps it itself.
if (this._bufferRefreshPending && !this._destroyed) return;
const owed = this._markerOwed;
this._markerOwed = false;
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
}
// Extracted so both _onSocketClosed() and a load that ends owing it on a
// closed socket can write it (see _stampMarkerIfOwed()).
_writeDisconnectedMarker() {
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
}
@@ -351,6 +376,7 @@
} catch {
/* Best-effort — live output still arrives once the socket connects. */
} finally {
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
@@ -430,27 +456,44 @@
// main thread) and replays it under the reader's current place. Holds the
// single-flight flag across the fetch AND the replay, like _loadBuffer().
async _pullHistory() {
// A close before the pull already wrote its marker; one during it did not.
const closedBefore = this._wsClosed;
this._bufferLoading = true;
this._liveQueue = [];
let replayed = false;
let capturedAt = 0;
// Two budgets on one signal. The request itself gets the primary pane's
// (CodemanFetchDeadline, constants.js): live output is not held while it
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
// for that whole budget. Once the headers land live output IS held, so the
// body read gets the short one instead: a body that hangs would otherwise
// freeze the pane for the long budget. Aborting lands in the catch below,
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
// be re-armed, hence the controller; without AbortController the pull
// simply has no deadline.
const controller = global.AbortController ? new global.AbortController() : null;
let abortTimer = null;
const armDeadline = (ms) => {
if (!controller) return;
clearTimeout(abortTimer);
abortTimer = setTimeout(() => controller.abort(), ms);
};
try {
// A deadline, because live output is held for as long as this runs: a
// request that hangs would otherwise freeze the whole pane. Aborting
// lands in the catch below, which releases the flag and the queue. It
// covers the body read too, not just the headers.
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
signal: global.AbortSignal?.timeout?.(HISTORY_PULL_TIMEOUT_MS),
signal: controller?.signal,
});
armDeadline(HISTORY_PULL_TIMEOUT_MS);
// The cutoff below is the response's arrival, the same `since` rule the
// primary pane uses (_finishBufferLoad). It is a client clock standing in
// for the instant tmux took the capture, which lies somewhere in the
// round trip, so a frame in that window can be lost or doubled. Bounded
// by one round trip and not closable without a server-side capture time.
capturedAt = performance.now();
// Opened only now: a frame from before the response is either replaced by
// the capture or written unchanged, so holding it for the round trip
// bought nothing and froze the pane for as long as the fetch took.
this._liveQueue = [];
const payload = (await res.json())?.data;
clearTimeout(abortTimer);
const buffer = payload?.terminalBuffer;
const term = this.terminal;
if (!buffer || !term || this._destroyed) return;
@@ -476,6 +519,7 @@
this._historyPullUseless = false;
term.write('\x1bc');
replayed = true;
if (this._wsClosed) this._markerOwed = true;
await writeChunked(term, buffer, () => this._destroyed);
if (this._destroyed || !this.terminal) return;
// xterm parses asynchronously: an empty write's callback fires only
@@ -490,6 +534,7 @@
} catch {
/* Best-effort — live output keeps arriving whatever happens here. */
} finally {
clearTimeout(abortTimer);
const queued = this._liveQueue ?? [];
this._liveQueue = null;
// After a replay, only frames that arrived after the capture are news;
@@ -500,15 +545,14 @@
if (entry.clear) this.terminal?.clear();
else this.terminal?.write(entry.data);
}
// A replay's own `\x1bc` wipes a marker written before the pull,
// painting a fresh, current-looking history while onData keeps
// silently dropping every keystroke on the dead socket, so re-stamp it
// after a replay. A close DURING the pull wrote no marker at all
// (_onSocketClosed() defers it while the queue is live), so write it
// whether or not this pull replayed. Checked after the queue flush so
// it is the last thing on screen, matching what the close would have
// left had the pull never run.
if (this._wsClosed && (replayed || !closedBefore)) this._writeDisconnectedMarker();
// Settled after the queue flush so the marker is the last thing on
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
// one written before it, which would paint a fresh, current-looking
// history while onData keeps silently dropping every keystroke on the
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
// is left to that refresh, which writes it below its own replay.
this._stampMarkerIfOwed();
this._endBufferLoad();
}
}
@@ -525,6 +569,10 @@
return;
}
this.terminal?.clear();
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
// trailing refresh behind a pull that the socket's close then interrupts),
// so a refresh on a closed socket owes it back once its replay is written.
if (this._wsClosed) this._markerOwed = true;
void this._loadBuffer();
}
+274 -8
View File
@@ -55,6 +55,9 @@
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
// focus itself, and the accessory bar, which is built to be used while the
// keyboard is open.
// ⚠️ A roving-tabindex widget parks every item but one at tabindex=-1, so the
// `[tabindex]` arm cannot see its items: the grouped tab rail's rows and
// headers are listed by role instead, or tapping one would drop the keyboard.
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
'input',
'textarea',
@@ -64,6 +67,7 @@
'[contenteditable=""]',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
'[role="treeitem"]',
'.keyboard-accessory-bar',
'.path-picker-overlay',
].join(',');
@@ -250,6 +254,235 @@ Object.assign(CodemanApp.prototype, {
this._keyCode229Recovery = null;
},
_destroyMobileImePreview() {
try {
this._mobileImePreview?.destroy?.();
} catch {
// The preview is visual-only; terminal replacement must continue.
}
this._mobileImePreview = null;
this._mobileImePreviewSessionId = null;
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreviewNode?.remove?.();
} catch {
// Best-effort node cleanup only.
}
try {
this._mobileImePreviewHelpers?.classList?.remove('codeman-ime-preview-owned');
} catch {
// Best-effort ownership cleanup only.
}
this._mobileImePreviewNode = null;
this._mobileImePreviewHelpers = null;
try {
if (this._mobileImePreviewOfflineHandler) {
window.removeEventListener('offline', this._mobileImePreviewOfflineHandler);
}
if (this._mobileImePreviewPagehideHandler) {
window.removeEventListener('pagehide', this._mobileImePreviewPagehideHandler);
}
} catch {
// Best-effort listener cleanup only.
}
this._mobileImePreviewOfflineHandler = null;
this._mobileImePreviewPagehideHandler = null;
},
/**
* iOS Safari IME preview (mobile-ime-preview.js). WebKit does not show the
* text an IME is composing inside the terminal, so the user types blind.
*
* Two homes, chosen per render:
* - Local echo on: typed text sits in the LocalEchoOverlay and the PTY
* cursor stays at the prompt start, under the overlay's opaque text (z 7,
* `.xterm-screen`). So the overlay draws the composition itself, as an
* underlined tail after its pending text (`setComposition`).
* - Otherwise (a shell, or the overlay could not place it): a span inside
* `.xterm-helpers`, positioned by the same --xterm-helper-left/top vars as
* the helper textarea, which follow the PTY cursor.
*
* Visual only: nothing here touches the input path, and every failure
* leaves no DOM behind.
*/
_initMobileImePreview() {
this._destroyMobileImePreview();
let preview = null;
let helpers = null;
try {
if (typeof MobileImePreview === 'undefined' || !MobileImePreview?.isIosWebKitTouch?.()) return;
const textarea = this.terminal?.textarea;
helpers = this.terminal?.element?.querySelector?.('.xterm-helpers');
if (!textarea || !helpers) return;
preview = document.createElement('span');
this._mobileImePreviewNode = preview;
this._mobileImePreviewHelpers = helpers;
preview.className = 'codeman-ime-preview';
preview.setAttribute('aria-hidden', 'true');
preview.hidden = true;
helpers.appendChild(preview);
const syncPreviewTypography = () => {
try {
const compositionView =
helpers.querySelector?.('.composition-view') || this.terminal?.element?.querySelector?.('.composition-view');
if (!compositionView || !preview.style) return;
const style = typeof getComputedStyle === 'function' ? getComputedStyle(compositionView) : compositionView.style;
for (const property of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'height']) {
const value = style?.[property] || compositionView.style?.[property];
if (value) preview.style[property] = value;
}
const theme = this.terminal?.options?.theme;
let foreground = theme?.foreground;
let background = theme?.background;
if (!foreground || !background) {
try {
const current = window.codemanCurrentXtermTheme?.();
foreground = foreground || current?.foreground;
background = background || current?.background;
} catch {
// Theme lookup is best-effort; retain the safe terminal fallback.
}
}
preview.style.color = foreground || '#e0e0e0';
// Opaque, like xterm's own composition view, so the preview does not
// overprint whatever sits at the cursor (a dim composer placeholder).
preview.style.backgroundColor = background || '#0d0d0d';
} catch {
// Typography matching is visual-only and must not block input.
}
};
// The overlay only when it is what shows typed text right now (local echo
// on, and not handed back to plain PTY echo by a composer nav key).
const localEchoOverlay = () =>
this._localEchoEnabled && !this._echoPassthroughSessions?.has(this.activeSessionId)
? this._localEchoOverlay || null
: null;
const clearOverlayComposition = () => {
try {
if (this._localEchoOverlay?.composition) this._localEchoOverlay.setComposition('');
} catch {}
};
const hideSpan = () => {
try {
preview.hidden = true;
} catch {}
try {
preview.textContent = '';
} catch {}
try {
delete preview.dataset.phase;
} catch {}
try {
helpers.classList.remove('codeman-ime-preview-owned');
} catch {}
};
const clearPreview = () => {
clearOverlayComposition();
hideSpan();
};
const controller = MobileImePreview.create({
textarea,
// An ancestor of the textarea: its capture-phase keydown listener runs
// before xterm's capture listener on the textarea, which finalizes the
// composition and emits the commit synchronously.
keydownTarget: this.terminal.element,
render: ({ text, phase }) => {
try {
const overlay = localEchoOverlay();
if (overlay && typeof overlay.setComposition === 'function') {
overlay.setComposition(text);
// No prompt found = nothing drawn: fall back to the span.
if (!text || overlay.state?.visible) {
hideSpan();
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
return;
}
overlay.setComposition('');
} else {
clearOverlayComposition();
}
syncPreviewTypography();
preview.textContent = text;
preview.dataset.phase = phase;
preview.hidden = !text;
helpers.classList.toggle('codeman-ime-preview-owned', !!text);
} catch {
clearPreview();
}
},
clear: clearPreview,
});
this._mobileImePreview = controller;
this._mobileImePreviewSessionId = this.activeSessionId;
// Offline and pagehide only reset: initTerminal() runs once per page
// load, so destroying on pagehide would leave the preview off for good
// after a back-forward cache restore (iOS Safari keeps pages there).
this._mobileImePreviewOfflineHandler = () => {
try {
this._mobileImePreview?.reset?.();
} catch {
// Disconnect cleanup is visual-only.
}
};
this._mobileImePreviewPagehideHandler = this._mobileImePreviewOfflineHandler;
window.addEventListener('offline', this._mobileImePreviewOfflineHandler);
window.addEventListener('pagehide', this._mobileImePreviewPagehideHandler);
} catch {
this._destroyMobileImePreview();
}
},
/**
* Tell the IME preview about a chunk xterm emitted through onData. Returns
* true when the chunk is the IME's committed text, in which case the preview
* holds it (phase 'committed') until something else shows it. Never throws.
*/
_consumeMobileImeTerminalData(data) {
let isImeCommit = false;
try {
isImeCommit = this._mobileImePreview?.consumeTerminalData?.(data) === true;
} catch {
// The preview is visual-only; normal terminal input must continue.
}
// Output accepted from here on can carry the echo of this commit.
if (isImeCommit) this._mobileImeCommitOutputSeq = this._terminalOutputSeq || 0;
return isImeCommit;
},
/**
* Clear a committed IME preview once terminal output accepted AFTER the
* commit has been parsed. `flushedOutputSeq` is the output sequence a fully
* written flush covered (null when part of it was deferred), so output that
* was already queued before the commit can never clear it early.
*/
_noteMobileImeAuthoritativeOutput(flushedOutputSeq, sessionId) {
try {
const commitSeq = this._mobileImeCommitOutputSeq;
if (commitSeq === null || commitSeq === undefined || flushedOutputSeq === null) return;
if (sessionId !== this.activeSessionId || !(flushedOutputSeq > commitSeq)) return;
this._mobileImeCommitOutputSeq = null;
this._mobileImePreview?.noteAuthoritativeOutput?.();
} catch {
// Authoritative output is never delayed or consumed by the preview.
}
},
/**
* The local echo overlay has just taken a committed IME chunk through the
* ordinary printable/paste branch, so it now shows the text: release the
* preview instead of waiting for terminal output.
*/
_transferMobileImeCommitToLocalEcho() {
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreview?.completeCommit?.({ predicted: true });
} catch {
// Ownership transfer is visual-only.
}
},
initTerminal() {
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
@@ -307,6 +540,7 @@ Object.assign(CodemanApp.prototype, {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._initMobileImePreview();
this._installMobileTapMouseGuard();
this._installShiftDragSelection();
this._installTouchSelectionFocusGuard();
@@ -447,8 +681,11 @@ Object.assign(CodemanApp.prototype, {
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
// which the inner application recognizes as "insert newline" vs carriage return.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
if (this.activeSessionId) {
// This handler also runs for keypress/keyup: xterm drops a keypress carrying Ctrl/Alt
// but NOT one carrying only Shift, so unless every event type is swallowed here,
// Shift+Enter's keypress sends a bare \r (submit) after the newline. Only keydown sends.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
if (ev.type === 'keydown' && this.activeSessionId) {
if (this._localEchoEnabled) {
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
@@ -1170,9 +1407,10 @@ Object.assign(CodemanApp.prototype, {
// has to re-resolve their gate before the redraw, not just move them.
this.applyLineageLineSettings?.();
this.updateConnectionLines();
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
// Unguarded on purpose: hasPending excludes an IME composition, so a
// composition-only overlay would stay on the old prompt row. rerender()
// is a no-op when the overlay has nothing to draw.
this._localEchoOverlay?.rerender();
// Pane B (split view) has its own container and its own fit()/resize
// frame — this observer only ever measured Pane A's container, so
// without this call Pane B never learned about a window resize, an
@@ -1212,6 +1450,8 @@ Object.assign(CodemanApp.prototype, {
// survives tab switches and reconnects.
const handleTerminalData = (data) => {
// Before anything can rewrite `data`: is this chunk the IME's commit?
const isImeCommit = this._consumeMobileImeTerminalData(data);
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
// the PTY even while the CJK input field owns focus. Without this exception
// tapping to move the cursor silently does nothing whenever Chinese input
@@ -1289,6 +1529,8 @@ Object.assign(CodemanApp.prototype, {
// When enabled, keystrokes are buffered locally in the overlay for
// instant visual feedback. Nothing is sent to the PTY until Enter
// (or a control char) is pressed — avoids out-of-order char delivery.
// An IME commit takes the same printable/paste branch as typed text,
// and the overlay then shows it in place of the preview.
if (this._localEchoEnabled && !echoPassthrough) {
if (data === '\x7f') {
const source = this._localEchoOverlay?.removeChar();
@@ -1345,6 +1587,7 @@ Object.assign(CodemanApp.prototype, {
if (data.length > 1 && data.charCodeAt(0) >= 32) {
// Paste: append to overlay only (sent on Enter)
this._localEchoOverlay?.appendText(data);
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
return;
}
if (data.charCodeAt(0) < 32) {
@@ -1490,6 +1733,7 @@ Object.assign(CodemanApp.prototype, {
if (data.length === 1 && data.charCodeAt(0) >= 32) {
// Printable char: add to overlay only (sent on Enter)
this._localEchoOverlay?.addChar(data);
if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
return;
}
}
@@ -3155,6 +3399,7 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const effort = this.getEffortSetting(globalSettings);
const advisorModel = this.getAdvisorSetting(globalSettings);
// `resumeSessionId` is a Claude conversation UUID (server reads it from
// ~/.claude/projects); an external-CLI row has no such thing, so sending
// it there gets silently ignored while the OMITTED `mode` field defaults
@@ -3204,6 +3449,8 @@ Object.assign(CodemanApp.prototype, {
...modeConfig,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
// The advisor is a claude-only feature; other CLIs would carry it inertly.
...(advisorModel && effectiveMode === 'claude' ? { advisorModel } : {}),
}),
});
const createData = await createRes.json();
@@ -3522,6 +3769,9 @@ Object.assign(CodemanApp.prototype, {
},
batchTerminalWrite(data) {
// Arrival order of output, so the IME preview can tell output that
// followed a commit from output that was already queued before it.
this._terminalOutputSeq = (this._terminalOutputSeq || 0) + 1;
// Feed the renderer watchdog. Recorded before the buffer-load early return
// below: a write that is queued rather than written still means the pipeline
// owes us a frame once it drains.
@@ -3585,6 +3835,7 @@ Object.assign(CodemanApp.prototype, {
// Accumulate raw data (may contain DEC 2026 markers)
this.pendingWrites.push(data);
this._pendingWritesOutputSeq = this._terminalOutputSeq;
this._scheduleTerminalWriteFlush();
},
@@ -3616,6 +3867,7 @@ Object.assign(CodemanApp.prototype, {
// Transfer buffered data to normal pending writes
this.pendingWrites.push(this.flickerFilterBuffer);
this._pendingWritesOutputSeq = this._terminalOutputSeq;
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
@@ -3646,6 +3898,15 @@ Object.assign(CodemanApp.prototype, {
* Position is tracked dynamically by _findPrompt() on every render.
*/
_updateLocalEchoState() {
if (this._mobileImePreviewSessionId !== this.activeSessionId) {
this._mobileImePreviewSessionId = this.activeSessionId;
this._mobileImeCommitOutputSeq = null;
try {
this._mobileImePreview?.reset?.();
} catch {
// The preview is visual-only; session switching must continue.
}
}
const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
@@ -3851,6 +4112,9 @@ Object.assign(CodemanApp.prototype, {
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
deferred = true;
}
// Newest output this chunk fully contains, for the IME preview. A split
// chunk may not hold that output yet, so it reports nothing.
const flushedOutputSeq = deferred ? null : (this._pendingWritesOutputSeq ?? null);
this._terminalWriteInFlight = true;
this._terminalWriteInFlightBytes = writeChunk.length;
try {
@@ -3868,6 +4132,7 @@ Object.assign(CodemanApp.prototype, {
// because the test's write mock moved the viewport synchronously.)
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
this._scheduleTerminalWriteFlush();
this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId);
});
} catch (err) {
this._terminalWriteInFlight = false;
@@ -3897,9 +4162,10 @@ Object.assign(CodemanApp.prototype, {
// Re-position local echo overlay after terminal writes — Ink redraws can
// move the ❯ prompt to a different row, making the overlay invisible.
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
// Unguarded on purpose: hasPending excludes an IME composition, so a
// composition-only overlay (the first word of a prompt) would otherwise
// stay on the old row. rerender() is a no-op when there is nothing to draw.
this._localEchoOverlay?.rerender();
// After Tab completion: detect the completed text in the overlay.
// Use terminal.write('', callback) to defer detection until xterm.js
+21 -10
View File
@@ -304,13 +304,26 @@ Object.assign(CodemanApp.prototype, {
let idx = startIndex;
for (const id of this.webviewOrder) {
const webview = this.webviews.get(id);
if (!webview) continue;
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
if (!this.webviews.get(id)) continue;
parts.push(this.renderWebviewTab(id, idx));
idx++;
}
return parts.join('');
},
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
/**
* One web tab's HTML; `idx` is its zero-based Alt+N slot (no badge from 9 up).
* The grouped vertical rail places single web tabs into their group with this,
* so a web tab's markup is the same in every layout.
*/
renderWebviewTab(id, idx) {
const webview = this.webviews.get(id);
if (!webview) return '';
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
@@ -322,10 +335,7 @@ Object.assign(CodemanApp.prototype, {
</span>
</span>
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">&#x2699;</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">&times;</span></span>
</div>`);
idx++;
}
return parts.join('');
</div>`;
},
_webviewGlobeIcon() {
@@ -348,6 +358,7 @@ Object.assign(CodemanApp.prototype, {
// A web tab is active, so no session tab may also look active.
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
}
this._syncTabTreeSelection?.(container);
},
// ── Opening / closing ─────────────────────────────────────────────────────
+2 -1
View File
@@ -18,7 +18,8 @@
* session record involved. A dropped plan therefore returns the user to
* resuming by hand, one at a time, which is where they are without this
* feature. What the plan held that a transcript does not is the owner, the
* name, the env overrides, the effort and the lineage.
* name, the env overrides, the effort, the model, the advisor model and the
* lineage.
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
* import and no IO, which keeps it unit-testable and cycle-free.
* - Spending is take-then-build: `take()` removes entries synchronously, before
+2
View File
@@ -28,6 +28,8 @@ export { registerWsRoutes } from './ws-routes.js';
export { registerVoiceRoutes } from './voice-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
export {
registerCustomModelRoutes,
refreshAllCustomModelHosts,
+97
View File
@@ -0,0 +1,97 @@
/**
* @fileoverview MCP server sync (src/mcp-sync.ts).
*
* GET /api/mcp-sync — dry run: per participating CLI, which servers it has and which it would gain.
* POST /api/mcp-sync — apply: add the missing servers to each CLI's own config file.
*
* Opt-in: both verbs answer 403 until `mcpSyncEnabled` is on (default OFF), because this writes
* OTHER tools' own user config. Writes files in the SERVER user's home, so in multi-user mode it
* is admin only. A second apply while one is running answers 409. Responses carry server names
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file; one that is enabled but absent from the machine is reported
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type McpSyncResult,
} from '../../types.js';
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { enabledClis } from '../../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
import { McpSyncBusyError, syncMcpServers, type McpSyncTarget } from '../../mcp-sync.js';
/** Default OFF, same shape as `readCliManagementEnabled`: read fresh so a toggle applies at once. */
export async function readMcpSyncEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.mcpSyncEnabled === true;
}
/** Enabled CLIs that declare an MCP config file, in registry order (first definition wins). */
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
return enabledClis()
.filter((e) => e.capabilities.mcpConfig)
.sort((a, b) => a.order - b.order)
.map((e) => ({
id: e.id,
label: e.label,
...e.capabilities.mcpConfig!,
installed: isCliEntryInstalled(e, availability),
}));
}
/**
* Installed, enabled agent CLIs with no known MCP config file (sync cannot touch them). One that
* is not installed is left out, the same way a supported one that is not installed reads `absent`.
*/
export function mcpUnsupportedLabels(availability: Record<string, boolean>): string[] {
return enabledClis()
.filter((e) => e.kind === 'agent' && !e.capabilities.mcpConfig && isCliEntryInstalled(e, availability))
.map((e) => e.label);
}
async function gate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
if (isMultiUserMode() && !isAdmin(req)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
if (!(await readMcpSyncEnabled())) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'MCP sync is disabled. Turn on "Enable MCP server sync" in Settings and save first.'
);
}
return null;
}
export function registerMcpSyncRoutes(app: FastifyInstance): void {
const run = async (req: FastifyRequest, reply: FastifyReply, apply: boolean): Promise<ApiResponse<McpSyncResult>> => {
const denied = await gate(req);
if (denied) {
reply.code(403);
return denied;
}
try {
const availability = await probeStockCliAvailability();
const targets = mcpSyncTargets(availability);
const data = await syncMcpServers(targets, { apply, env: process.env }, mcpUnsupportedLabels(availability));
return { success: true, data };
} catch (err) {
if (err instanceof McpSyncBusyError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
};
app.get('/api/mcp-sync', (req, reply) => run(req, reply, false));
app.post('/api/mcp-sync', (req, reply) => run(req, reply, true));
}
+2
View File
@@ -293,6 +293,7 @@ export function registerRalphRoutes(
planItems,
envOverrides,
effort,
advisorModel,
} = parseBody(RalphLoopStartSchema, req.body);
// Multi-user: cases live in the requesting user's space.
@@ -343,6 +344,7 @@ export function registerRalphRoutes(
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
effort,
advisorModel,
owner: rlOwner,
});
+2
View File
@@ -195,6 +195,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
),
effort: saved.effort,
model: saved.model,
advisorModel: saved.advisorModel,
attachmentHistory:
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
+55 -16
View File
@@ -30,7 +30,13 @@ import {
type OmpConfig,
type RemoteHost,
} from '../../types.js';
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import {
Session,
cliTakesSessionModel,
isAltScreenStripMode,
isExternalCliMode,
isMuxAltScreenOnlyStripMode,
} from '../../session.js';
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
@@ -107,6 +113,7 @@ import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-mar
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import type { NewlineSequence } from '../../config/cli-registry/types.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
import { isMultiUserMode } from '../../config/multiuser.js';
@@ -888,6 +895,25 @@ export function registerSessionRoutes(
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
const body = parseBody(CreateSessionSchema, req.body);
// The top-level `model` is Claude's per-session `--model`. Every other CLI takes its model
// in its own config object (`codexConfig.model` and so on), so a `model` here would be
// dropped without a word; refuse it before anything is written for the session.
if (body.model && !cliTakesSessionModel(body.mode ?? 'claude')) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model applies to claude sessions only; other CLIs take their model in their own config object, such as codexConfig.model'
);
}
// An attach launches nothing (the remote agent is already running), so a launch model
// or advisor would be dropped the same way, so both are refused, as they have been since
// they were added. The older launch fields (effort, envOverrides) predate this and keep
// their silent ignore here, since refusing them now would break existing callers.
if (body.attachRemoteSession && (body.model || body.advisorModel)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'model and advisorModel shape a new launch, and attachRemoteSession launches nothing; leave them out when attaching'
);
}
let workingDir = body.workingDir || process.cwd();
let remote = undefined;
@@ -1073,9 +1099,10 @@ export function registerSessionRoutes(
// genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent
// in that CLI's own config object.
// 'claude-settings-file' — claude alone, whose model is written to
// <case>/.claude/settings.local.json rather than passed as
// a flag, so the app-wide default applies here.
// 'claude-settings-file' — claude alone, whose persistent model is written to
// <case>/.claude/settings.local.json (`modelOverride`). A
// per-session `model` from the caller goes out as --model and
// wins; without one, the app-wide default applies.
// 'none' — shell has no model; deepseek's is a composition entry in
// the profile's config tree, not a session field
// (docs/deepseek-integration.md). Both get nothing.
@@ -1086,7 +1113,7 @@ export function registerSessionRoutes(
| string
| undefined)
: modelSource?.source === 'claude-settings-file'
? modelConfig?.defaultModel || undefined
? body.model || modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
@@ -1130,6 +1157,7 @@ export function registerSessionRoutes(
resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort,
advisorModel: body.advisorModel,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
remote,
owner,
@@ -2092,21 +2120,16 @@ export function registerSessionRoutes(
// ========== Send Named Key (tmux send-keys -H) ==========
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
// Uses send-keys -H (hex) to inject a newline chord: 0x0a (line feed) by default, or the CLI's
// own `capabilities.newline`. Claude Code's Ink input recognizes 0x0a as "insert newline" vs
// 0x0d (carriage return = submit).
app.post('/api/sessions/:id/send-key', async (req) => {
const { id } = req.params as { id: string };
const body = req.body as Record<string, unknown>;
const key = typeof body?.key === 'string' ? body.key : '';
// Map key names to hex byte sequences
const KEY_HEX_MAP: Record<string, string[]> = {
'S-Enter': ['0a'], // \n (line feed)
'C-Enter': ['0a'], // \n (line feed)
};
const hex = KEY_HEX_MAP[key];
if (!hex) {
if (key !== 'S-Enter' && key !== 'C-Enter') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
}
@@ -2116,6 +2139,18 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
}
// Key names map to hex byte sequences. Ctrl+Enter is always a line feed; Shift+Enter is the
// CLI's own newline chord (`capabilities.newline`, default line feed), so a CLI that wants
// Esc+Enter declares it in the registry instead of being special-cased here.
const NEWLINE_HEX: Record<NewlineSequence, string[]> = {
'line-feed': ['0a'], // \n
'esc-enter': ['1b', '0d'], // ESC CR, the Alt/Option+Enter chord
};
const hex =
key === 'C-Enter'
? NEWLINE_HEX['line-feed']
: NEWLINE_HEX[getCli(session.mode)?.capabilities.newline ?? 'line-feed'];
try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
@@ -3393,6 +3428,7 @@ export function registerSessionRoutes(
ompConfig,
envOverrides,
effort,
advisorModel,
parentSessionId,
agentOrigin,
customModel,
@@ -3440,6 +3476,7 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
advisorModel ||
modelOverride !== undefined ||
codexConfig ||
geminiConfig ||
@@ -3453,7 +3490,7 @@ export function registerSessionRoutes(
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
'envOverrides, effort, advisorModel, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
);
}
@@ -3510,6 +3547,7 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
advisorModel ||
codexConfig ||
geminiConfig ||
antigravityConfig ||
@@ -3522,7 +3560,7 @@ export function registerSessionRoutes(
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
'envOverrides, effort, advisorModel, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
);
}
@@ -3977,6 +4015,7 @@ export function registerSessionRoutes(
ompConfig: qsResolvedOmpConfig,
envOverrides: qsCustomModelEnvOverrides,
effort,
advisorModel,
remote,
docker,
resumeSessionId: dockerResumeId,
+109
View File
@@ -0,0 +1,109 @@
/**
* @fileoverview Webhook notification settings (src/webhook-notify.ts).
*
* GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result
* PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it
* POST /api/webhook/test — send one test message with the saved config
*
* The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is
* stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only:
* the channel receives every session's events, the same reach an admin's own Web Push has.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type WebhookResult,
type WebhookStatus,
} from '../../types.js';
import { isAdmin, parseBody } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { WebhookUpdateSchema } from '../schemas.js';
import {
maskWebhookUrl,
readWebhookConfig,
webhookUrlProblem,
writeWebhookConfig,
type WebhookNotifier,
} from '../../webhook-notify.js';
export interface WebhookRouteDeps {
notifier: WebhookNotifier;
configDir: string;
/** The instance's window title, so a test message says which machine sent it. */
hostTitle: () => string;
}
export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void {
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
const status = async (): Promise<WebhookStatus> => {
const cfg = await readWebhookConfig(deps.configDir);
return {
enabled: cfg.enabled,
kind: cfg.kind,
scope: cfg.scope,
hasUrl: cfg.url !== '',
urlMasked: maskWebhookUrl(cfg.url),
lastResult: deps.notifier.lastResult,
};
};
app.get('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
return { success: true, data: await status() };
});
app.put('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
const no = denied(req, reply);
if (no) return no;
const patch = parseBody(WebhookUpdateSchema, req.body, 'Invalid webhook settings');
const current = await readWebhookConfig(deps.configDir);
const next = {
enabled: patch.enabled ?? current.enabled,
kind: patch.kind ?? current.kind,
scope: patch.scope ?? current.scope,
url: patch.url !== undefined ? patch.url.trim() : current.url,
};
if (next.url) {
const problem = webhookUrlProblem(next.url);
if (problem) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
}
if (next.enabled && !next.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Add a webhook URL before enabling notifications');
}
try {
await writeWebhookConfig(deps.configDir, next);
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
return { success: true, data: await status() };
});
app.post('/api/webhook/test', async (req, reply): Promise<ApiResponse<WebhookResult>> => {
const no = denied(req, reply);
if (no) return no;
const cfg = await readWebhookConfig(deps.configDir);
if (!cfg.url) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save a webhook URL first');
}
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether the webhook did.
return { success: true, data: await deps.notifier.sendTest(cfg, deps.hostTitle()) };
});
}
+82
View File
@@ -18,11 +18,14 @@ import {
MIN_TERMINAL_SCROLLBACK_LINES,
} from '../config/terminal-history.js';
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { isAdvisorModel } from '../types/session.js';
// ========== Path Validation ==========
@@ -251,6 +254,20 @@ const safeEnvOverridesSchema = z
*/
const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional();
/**
* Claude advisor model for new sessions: `fable`/`opus`/`sonnet` or a full model id in one of
* those families (isAdvisorModel). Merged into the launch `--settings` JSON as `advisorModel`,
* a soft default that /advisor still switches in-session. The allowlist is also the injection
* guard for the single-quoted `--settings` argument.
*/
const advisorModelSchema = z
.string()
.max(64)
.refine((value) => isAdvisorModel(value), {
message: 'advisorModel must be fable, opus, sonnet or a full claude-fable/opus/sonnet model id',
})
.optional();
// ========== Session Routes ==========
/**
@@ -298,6 +315,7 @@ const CodexConfigSchema = z
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
reasoningEffort: z.enum(CODEX_REASONING_EFFORTS).optional(),
resumeSessionId: z
.string()
.max(100)
@@ -524,8 +542,25 @@ export const CreateSessionSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
/**
* Claude model for THIS session only, passed as `claude --model <id>`; nothing is written to
* disk. Wins over the app-wide default model. A subset of the registry's `model-claude`
* pattern, so a value accepted here is never rejected at launch. The first character must be
* a letter or digit: the value lands in argv, and no model id opens with `-`, so a
* flag-shaped value is refused here rather than left to the launch quoting. An empty string
* means no per-session model, as it does for `modelOverride`. Claude only: the route refuses
* it for any other CLI and on a remote attach.
*/
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-[\]]*$/)
.or(z.literal(''))
.optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
@@ -1055,6 +1090,8 @@ export const QuickStartSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
/**
* Who is spawning this worker (`codeman-skill` from the packaged agent skill), or,
* equivalently, the `X-Codeman-Agent-Origin` header; the body wins when both are
@@ -1327,6 +1364,13 @@ export const SettingsUpdateSchema = z
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
*/
cliManagementEnabled: z.boolean().optional(),
/**
* MCP server sync (src/mcp-sync.ts): copies each enabled CLI's user-level MCP servers into
* the other CLIs' own config files. SYNCED, default OFF: it writes other tools' config in
* the server user's home (including any env values and headers on the servers), so it is
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
*/
mcpSyncEnabled: z.boolean().optional(),
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
@@ -1352,6 +1396,14 @@ export const SettingsUpdateSchema = z
* rail drag-reorderable.
*/
tabRailSort: z.enum(['activity', 'manual']).optional(),
/**
* Tab grouping. Display key (per-device).
* 'state' = the strip is split into state rows (needs you, waiting,
* working, idle; Discussion #426 option C), and the flat side
* rail and the sidebar get the same sections. The default.
* 'none' = one flat list in tab order, as before.
*/
tabGrouping: z.enum(['state', 'none']).optional(),
/**
* Session list layout. Display key (per-device).
* 'header' = horizontal tab strip
@@ -1374,9 +1426,23 @@ export const SettingsUpdateSchema = z
// auto-reattached.
remoteAutoReconnect: z.boolean().optional(),
thinkingEffort: z.string().max(20).optional(),
/** Advisor model for new Claude sessions ('' = leave it to the CLI's own /advisor choice). */
claudeAdvisorModel: z
.string()
.max(64)
.refine((value) => value === '' || isAdvisorModel(value), { message: 'Invalid advisor model' })
.optional(),
// UI visibility
showFontControls: z.boolean().optional(),
showSystemStats: z.boolean().optional(),
/**
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
* (per-device), desktop only (the cluster is hidden below 768px).
* 'classic' = the bars and the 5H · 7D chip, as before
* 'compact' = one system pill with sparklines plus a plan-usage ring pill
* 'tiles' = label over value with a bar underneath, no icons. The default.
*/
headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(),
showTokenCount: z.boolean().optional(),
showCost: z.boolean().optional(),
showLifecycleLog: z.boolean().optional(),
@@ -1846,6 +1912,20 @@ export const PushPreferencesUpdateSchema = z.object({
pushPreferences: z.record(z.string(), z.boolean()),
});
/**
* PUT /api/webhook. `.strict()` like every settings-shaped schema; `url` is optional so a change of
* kind or scope never needs the secret re-sent, and an empty string clears it. The kind and scope
* lists are the store's own, so the schema can never accept a value the store would coerce away.
*/
export const WebhookUpdateSchema = z
.object({
enabled: z.boolean().optional(),
kind: z.enum(WEBHOOK_KINDS).optional(),
scope: z.enum(WEBHOOK_SCOPES).optional(),
url: z.string().max(2048).optional(),
})
.strict();
// ========== Ralph Loop ==========
/** POST /api/ralph-loop/start */
@@ -1862,6 +1942,8 @@ export const RalphLoopStartSchema = z.object({
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Claude advisor model (soft default via --settings, switchable in-session via /advisor) */
advisorModel: advisorModelSchema,
planItems: z
.array(
z.object({
+56 -22
View File
@@ -44,6 +44,9 @@ import { hostname as getHostname, uptime as osUptime } from 'node:os';
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
import type { WebhookUrgency } from '../types/push.js';
import { webviewFetch } from './webview-egress.js';
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
import type { RemoteWakeRegistry } from '../remote-wake.js';
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
@@ -197,6 +200,8 @@ import {
registerVoiceRoutes,
registerWebviewRoutes,
registerTabLayoutRoutes,
registerMcpSyncRoutes,
registerWebhookRoutes,
registerCustomModelRoutes,
refreshAllCustomModelHosts,
readCustomModelEndpointsEnabled,
@@ -368,6 +373,8 @@ export class WebServer extends EventEmitter {
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
private userFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private readonly titleHostname: string;
@@ -1130,6 +1137,12 @@ export class WebServer extends EventEmitter {
registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
registerMcpSyncRoutes(this.app);
registerWebhookRoutes(this.app, {
notifier: this.webhookNotifier,
configDir: getDataDir(),
hostTitle: () => this.windowTitle,
});
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
@@ -2652,6 +2665,25 @@ export class WebServer extends EventEmitter {
const template = WebServer.PUSH_EVENT_MAP[event];
if (!template) return;
const sessionName = (data.sessionName as string) || '';
const sessionId = (data.sessionId as string) || '';
const body = WebServer.pushBodyText(event, data, sessionName);
// Webhook channel (ntfy / Slack / Discord / generic): independent of Web Push, so it runs BEFORE
// the "no subscriptions" return below, which is exactly the headless-server case it exists for.
// Fire-and-forget; WebhookNotifier dedupes, caps what is in flight and never throws.
void this.webhookNotifier
.notify({
event,
title: template.title,
body,
urgency: template.urgency as WebhookUrgency,
sessionId: sessionId || undefined,
sessionName: sessionName || undefined,
host: this.windowTitle,
})
.catch(() => undefined);
const subscriptions = this.pushStore.getAll();
if (subscriptions.length === 0) return;
@@ -2670,9 +2702,6 @@ export class WebServer extends EventEmitter {
const vapidKeys = this.pushStore.getVapidKeys();
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
const sessionName = (data.sessionName as string) || '';
const sessionId = (data.sessionId as string) || '';
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
// must reach only the owner's devices (+ admins) — the body embeds the session
// name + activity, so cross-user delivery would leak it. Resolved once here; the
@@ -2680,25 +2709,6 @@ export class WebServer extends EventEmitter {
const multiUserPush = isMultiUserMode();
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
// Build body text from event data
let body = sessionName ? `[${sessionName}]` : '';
if (event === SseEvent.SessionError && data.error) {
body += body ? ' ' : '';
body += String(data.error).slice(0, 200);
} else if (event === SseEvent.RespawnBlocked && data.reason) {
body += body ? ' ' : '';
body += String(data.reason);
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
body += body ? ' ' : '';
body += String(data.phrase);
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
body += body ? ' ' : '';
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
}
const payload = JSON.stringify({
title: template.title,
// Hostname-aware prefix so OS-level notifications from multiple Codeman
@@ -2752,6 +2762,28 @@ export class WebServer extends EventEmitter {
}
}
/** The notification body for an event (shared by Web Push and the webhook channel). */
private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string {
let body = sessionName ? `[${sessionName}]` : '';
if (event === SseEvent.SessionError && data.error) {
body += body ? ' ' : '';
body += String(data.error).slice(0, 200);
} else if (event === SseEvent.RespawnBlocked && data.reason) {
body += body ? ' ' : '';
body += String(data.reason);
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
body += body ? ' ' : '';
body += String(data.phrase);
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
body += body ? ' ' : '';
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
}
return body;
}
private cleanupDeadSSEClients(): void {
this.sse.cleanupDeadClients();
}
@@ -3489,6 +3521,8 @@ export class WebServer extends EventEmitter {
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
model: savedState?.model,
advisorModel: savedState?.advisorModel,
attachmentHistory: savedAttachmentHistory,
// The pane's last Enter. Without it the response viewer would show
// the launch conversation until the user types again, even though
+319
View File
@@ -0,0 +1,319 @@
/**
* @fileoverview Webhook notifications (ntfy, Slack, Discord, generic JSON) for the events that
* already trigger Web Push, so a headless server can reach a phone without a browser tab or a
* push subscription.
*
* Split in three, so the parts that matter are testable without a network:
* - pure: `webhookUrlProblem`, `maskWebhookUrl`, `shouldSendWebhook`, `buildWebhookRequest`
* - store: `~/.codeman/webhook.json`, written 0600 via tmp+rename (the URL is a bearer secret:
* anyone holding a Slack/Discord webhook URL can post as it)
* - IO: `sendWebhook` (injected fetch) and `WebhookNotifier` (dedupe, in-flight cap, last result)
*
* Rules the code keeps and the tests pin:
* - The URL is configured only through the admin-only `/api/webhook` routes and kept OUT of
* `settings.json`, which every logged-in user can read through `GET /api/settings`.
* - Delivery goes through `webviewFetch`: link-local and cloud-metadata targets are refused on
* the RESOLVED address at connect time, redirects are not followed, and the call is bounded
* by a timeout. Loopback and LAN stay allowed on purpose (a local ntfy is the feature).
* - The URL never appears in a log line, a result, or an error message.
* - Session names and error text are user/agent-controlled, so they cannot ping a channel:
* Discord gets `allowed_mentions: { parse: [] }` and Slack control characters are escaped.
*
* @module webhook-notify
*/
import { existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { blockedWebviewHostReason } from './web/webview-egress-policy.js';
import { isEgressBlockedError } from './web/webview-egress.js';
import {
WEBHOOK_KINDS,
WEBHOOK_SCOPES,
type WebhookConfig,
type WebhookKind,
type WebhookResult,
type WebhookScope,
type WebhookUrgency,
} from './types/push.js';
const WEBHOOK_FILE = 'webhook.json';
const MAX_URL_LENGTH = 2048;
const SEND_TIMEOUT_MS = 5000;
const MAX_BODY_CHARS = 500;
/** Same event + session within this window is sent once: a flapping prompt must not flood a channel. */
const DEDUPE_WINDOW_MS = 3000;
const MAX_IN_FLIGHT = 5;
export const DEFAULT_WEBHOOK_CONFIG: WebhookConfig = { enabled: false, kind: 'ntfy', url: '', scope: 'attention' };
export interface WebhookMessage {
event: string;
title: string;
body: string;
urgency: WebhookUrgency;
sessionId?: string;
sessionName?: string;
/** The Codeman instance's window title, so several machines are told apart. */
host?: string;
}
// ---------------------------------------------------------------------------
// Pure
// ---------------------------------------------------------------------------
/** Why `raw` cannot be a webhook URL, or null. Used at save time; delivery re-checks the resolved address. */
export function webhookUrlProblem(raw: string): string | null {
if (raw.length > MAX_URL_LENGTH) return 'URL is too long';
let url: URL;
try {
url = new URL(raw);
} catch {
return 'Not a valid URL';
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'Only http and https URLs are allowed';
if (url.username || url.password) return 'Put credentials in the path or a header-less token, not user:password@';
const blocked = blockedWebviewHostReason(url.hostname);
if (blocked) return `Refused: ${blocked}`;
return null;
}
/** Scheme + host only: the path and query of a webhook URL are the secret. */
export function maskWebhookUrl(raw: string): string {
try {
const url = new URL(raw);
return `${url.protocol}//${url.host}/•••`;
} catch {
return '';
}
}
export function shouldSendWebhook(cfg: WebhookConfig, urgency: WebhookUrgency): boolean {
if (!cfg.enabled || !cfg.url) return false;
return cfg.scope === 'all' || urgency !== 'info';
}
const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
/** A header value must be single-line printable ASCII; anything else goes out RFC 2047 encoded. */
function headerSafe(value: string): string {
const oneLine = value.replace(/[\r\n]+/g, ' ').trim();
return /^[\x20-\x7e]*$/.test(oneLine) ? oneLine : `=?UTF-8?B?${Buffer.from(oneLine, 'utf8').toString('base64')}?=`;
}
/** Slack parses `<!channel>`, `<@U123>` and `<url|text>`; escaping the three control characters turns them to text. */
const slackEscape = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const NTFY_PRIORITY: Record<WebhookUrgency, string> = { critical: '5', warning: '4', info: '3' };
const NTFY_TAGS: Record<WebhookUrgency, string> = {
critical: 'rotating_light',
warning: 'bell',
info: 'white_check_mark',
};
export interface WebhookRequest {
method: 'POST';
headers: Record<string, string>;
body: string;
}
export function buildWebhookRequest(kind: WebhookKind, msg: WebhookMessage, now: Date = new Date()): WebhookRequest {
const title = clip(msg.title, 120);
const body = clip(msg.body, MAX_BODY_CHARS);
const prefix = msg.host ? `${clip(msg.host, 60)}: ` : '';
switch (kind) {
case 'ntfy':
return {
method: 'POST',
headers: {
'Content-Type': 'text/plain; charset=utf-8',
Title: headerSafe(`${prefix}${title}`),
Priority: NTFY_PRIORITY[msg.urgency],
Tags: NTFY_TAGS[msg.urgency],
},
body: body || title,
};
case 'slack':
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `*${slackEscape(`${prefix}${title}`)}*${body ? `\n${slackEscape(body)}` : ''}` }),
};
case 'discord':
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: clip(`**${prefix}${title}**${body ? `\n${body}` : ''}`, 1900),
// Agent output and session names are not trusted to @everyone a channel.
allowed_mentions: { parse: [] },
}),
};
case 'generic':
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: msg.event,
title,
body,
urgency: msg.urgency,
sessionId: msg.sessionId ?? null,
sessionName: msg.sessionName ?? null,
host: msg.host ?? null,
at: now.toISOString(),
}),
};
}
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export function webhookConfigPath(configDir: string): string {
return join(configDir, WEBHOOK_FILE);
}
function coerce(raw: unknown): WebhookConfig {
const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
return {
enabled: r.enabled === true,
kind: (WEBHOOK_KINDS as readonly unknown[]).includes(r.kind)
? (r.kind as WebhookKind)
: DEFAULT_WEBHOOK_CONFIG.kind,
url: typeof r.url === 'string' ? r.url : '',
scope: (WEBHOOK_SCOPES as readonly unknown[]).includes(r.scope)
? (r.scope as WebhookScope)
: DEFAULT_WEBHOOK_CONFIG.scope,
};
}
export async function readWebhookConfig(configDir: string): Promise<WebhookConfig> {
try {
return coerce(JSON.parse(await fs.readFile(webhookConfigPath(configDir), 'utf-8')));
} catch {
return { ...DEFAULT_WEBHOOK_CONFIG };
}
}
/** 0600 via tmp+rename: `mode` on writeFile only applies to a file being created. */
export async function writeWebhookConfig(configDir: string, cfg: WebhookConfig): Promise<void> {
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
const target = webhookConfigPath(configDir);
const tmp = `${target}.${process.pid}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(coerce(cfg), null, 2), { mode: 0o600 });
await fs.rename(tmp, target);
} catch (err) {
await fs.unlink(tmp).catch(() => undefined);
throw err;
}
}
// ---------------------------------------------------------------------------
// IO
// ---------------------------------------------------------------------------
export type WebhookFetch = (target: URL, init: RequestInit) => Promise<Response>;
/**
* What went wrong, without the URL: blocked / timed out / refused / an HTTP status. An egress
* refusal is recognised by its `CODEMAN_EGRESS_BLOCKED` code anywhere in the cause chain (undici
* wraps the lookup's error as `TypeError('fetch failed', { cause })`), never by message text.
*/
function describeError(err: unknown): string {
const e = err as { name?: string; cause?: { code?: string } };
if (e?.name === 'TimeoutError' || e?.name === 'AbortError') return 'Timed out';
if (isEgressBlockedError(err)) return 'Refused: target is a link-local or cloud-metadata address';
if (e?.cause?.code === 'ENOTFOUND') return 'Host not found';
if (e?.cause?.code === 'ECONNREFUSED') return 'Connection refused';
return 'Network error';
}
export async function sendWebhook(
cfg: Pick<WebhookConfig, 'kind' | 'url'>,
msg: WebhookMessage,
fetchImpl: WebhookFetch
): Promise<WebhookResult> {
const at = Date.now();
const problem = webhookUrlProblem(cfg.url);
if (problem) return { ok: false, error: problem, at };
const req = buildWebhookRequest(cfg.kind, msg);
try {
const res = await fetchImpl(new URL(cfg.url), {
method: req.method,
headers: req.headers,
body: req.body,
redirect: 'manual',
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
});
void res.body?.cancel().catch(() => undefined);
if (res.status >= 300 && res.status < 400) {
return { ok: false, status: res.status, error: 'The URL redirects; use the final URL', at };
}
return res.ok
? { ok: true, status: res.status, at }
: { ok: false, status: res.status, error: `HTTP ${res.status}`, at };
} catch (err) {
return { ok: false, error: describeError(err), at };
}
}
/**
* Sends the notifications the server decides on. Fire-and-forget by design (a slow webhook must
* never delay Web Push or a request), so it dedupes, caps what is in flight, and remembers only
* the last result for the Settings status line.
*/
export class WebhookNotifier {
private lastSent = new Map<string, number>();
private inFlight = 0;
private last: WebhookResult | null = null;
constructor(
private readonly load: () => Promise<WebhookConfig>,
private readonly fetchImpl: WebhookFetch,
private readonly now: () => number = Date.now
) {}
get lastResult(): WebhookResult | null {
return this.last;
}
async notify(msg: WebhookMessage): Promise<void> {
const cfg = await this.load();
if (!shouldSendWebhook(cfg, msg.urgency)) return;
const key = `${msg.event}:${msg.sessionId ?? ''}`;
const t = this.now();
const prev = this.lastSent.get(key);
if (prev !== undefined && t - prev < DEDUPE_WINDOW_MS) return;
if (this.inFlight >= MAX_IN_FLIGHT) return;
this.lastSent.set(key, t);
if (this.lastSent.size > 256) {
for (const [k, v] of this.lastSent) if (t - v > DEDUPE_WINDOW_MS) this.lastSent.delete(k);
}
this.inFlight++;
try {
this.last = await sendWebhook(cfg, msg, this.fetchImpl);
} finally {
this.inFlight--;
}
}
/** A deliberate test send: bypasses `enabled`, scope and dedupe, and records the result. */
async sendTest(cfg: Pick<WebhookConfig, 'kind' | 'url'>, host?: string): Promise<WebhookResult> {
const result = await sendWebhook(
cfg,
{
event: 'webhook:test',
title: 'Codeman test notification',
body: 'If you can read this, webhook notifications are working.',
urgency: 'info',
host,
},
this.fetchImpl
);
this.last = result;
return result;
}
}
+304
View File
@@ -0,0 +1,304 @@
/**
* @fileoverview Tests for Claude Code's advisor tool (code.claude.com/docs/en/advisor)
* carried as a per-session `advisorModel`.
*
* The advisor rides the launch's ONE `--settings` JSON object as the `advisorModel` key,
* never the `--advisor` flag: the flag exits at launch on a pairing the CLI refuses
* (`claude --advisor haiku` prints "cannot be used as an advisor" and exits 1), while the
* settings key degrades to "no advisor". Verified against Claude Code 2.1.289 with
* `claude -p --settings '{"advisorModel":"opus"}' /advisor` → "Advisor: Opus 5.5".
*
* `--settings` is extracted through a REAL shell, as in statusline-cli-flag.test.ts, so the
* assertions see exactly what a spawned pane would.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process';
import { buildAdvisorSettings, buildInteractiveArgs } from '../src/session-cli-builder.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { isAdvisorModel, ADVISOR_MODEL_ALIASES } from '../src/types.js';
import { Session } from '../src/session.js';
import {
CreateSessionSchema,
QuickStartSchema,
RalphLoopStartSchema,
SettingsUpdateSchema,
} from '../src/web/schemas.js';
const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true';
/**
* What the direct-PTY fallback hands `pty.spawn`. The file and argv are recorded, then a
* harmless stand-in runs instead: the real `claude` must never start from a test, and the
* stand-in is a real process so `Session.stop()` has a real pid to signal.
*/
const ptySpawns = vi.hoisted(() => [] as Array<{ file: string; args: string[] }>);
vi.mock('node-pty', async (importOriginal) => {
const real = await importOriginal<typeof import('node-pty')>();
return {
...real,
spawn: (file: string, args: string[] | string, options: import('node-pty').IPtyForkOptions) => {
ptySpawns.push({ file, args: Array.isArray(args) ? args : [args] });
return real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options);
},
};
});
function extractSettingsJson(cmd: string): unknown {
const idx = cmd.indexOf('--settings ');
expect(idx).toBeGreaterThan(-1);
const out = execFileSync('bash', ['-c', `set -- ${cmd.slice(idx)}; printf '%s' "$2"`]).toString();
return JSON.parse(out);
}
describe('isAdvisorModel', () => {
it('accepts the documented aliases', () => {
for (const alias of ADVISOR_MODEL_ALIASES) expect(isAdvisorModel(alias)).toBe(true);
});
it('accepts full model ids in the advisor-capable families', () => {
expect(isAdvisorModel('claude-opus-5-5')).toBe(true);
expect(isAdvisorModel('claude-fable-5-1')).toBe(true);
expect(isAdvisorModel('claude-sonnet-5-5')).toBe(true);
});
it('rejects haiku, which can call an advisor but never act as one', () => {
expect(isAdvisorModel('haiku')).toBe(false);
expect(isAdvisorModel('claude-haiku-4-5-20251001')).toBe(false);
});
it('rejects anything that could break out of the quoted --settings argument', () => {
for (const bad of [
'',
'OPUS',
'opus[1m]',
"opus'; rm -rf /; '",
'opus"}',
'claude-opus-5-5 --dangerously-skip-permissions',
`claude-opus-${'5-'.repeat(40)}5`,
undefined,
null,
42,
]) {
expect(isAdvisorModel(bad)).toBe(false);
}
});
});
describe('buildAdvisorSettings', () => {
it('returns the settings key for a valid model and nothing otherwise', () => {
expect(buildAdvisorSettings('opus')).toEqual({ advisorModel: 'opus' });
expect(buildAdvisorSettings(undefined)).toEqual({});
expect(buildAdvisorSettings('haiku')).toEqual({});
});
});
describe('buildSpawnCommand advisorModel (tmux launch, claude mode)', () => {
it('rides --settings as the advisorModel key, never the --advisor flag', () => {
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', advisorModel: 'opus' });
expect(cmd).not.toContain('--advisor');
expect(cmd.match(/--settings/g)).toHaveLength(1);
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' });
});
it('merges ultracode, advisor and the statusLine exporter into ONE --settings object', () => {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
effort: 'ultracode',
advisorModel: 'fable',
statusLineCommand: EXPORTER_CMD,
});
expect(cmd.match(/--settings/g)).toHaveLength(1);
expect(extractSettingsJson(cmd)).toEqual({
ultracode: true,
advisorModel: 'fable',
statusLine: { type: 'command', command: EXPORTER_CMD },
});
});
it('keeps a regular --effort flag beside the advisor settings', () => {
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', effort: 'high', advisorModel: 'sonnet' });
expect(cmd).toContain("--effort 'high'");
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'sonnet' });
});
it('carries the advisor on the resume variant too', () => {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
resumeSessionId: '11111111-2222-3333-4444-555555555555',
advisorModel: 'opus',
});
expect(cmd).toContain('--resume');
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' });
});
it('leaves the command byte-identical when no advisor (or an invalid one) is set', () => {
for (const base of [
{ mode: 'claude', sessionId: 'sid-1' },
{ mode: 'claude', sessionId: 'sid-1', effort: 'ultracode' as const, statusLineCommand: EXPORTER_CMD },
]) {
const without = buildSpawnCommand(base);
expect(buildSpawnCommand({ ...base, advisorModel: undefined })).toBe(without);
expect(buildSpawnCommand({ ...base, advisorModel: 'haiku' })).toBe(without);
}
});
it('is inert for a CLI that has no --settings carrier', () => {
const cmd = buildSpawnCommand({ mode: 'codex', sessionId: 'sid-1', advisorModel: 'opus' });
expect(cmd).not.toContain('advisorModel');
});
});
describe('buildInteractiveArgs advisorModel (direct-PTY fallback)', () => {
const settingsOf = (args: string[]) => {
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
return JSON.parse(args[args.indexOf('--settings') + 1]);
};
it('adds a --settings object holding only the advisor', () => {
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, undefined, undefined, null, 'opus');
expect(args).not.toContain('--advisor');
expect(settingsOf(args)).toEqual({ advisorModel: 'opus' });
});
it("folds the advisor into ultracode's --settings object", () => {
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'ultracode', undefined, null, 'fable');
expect(settingsOf(args)).toEqual({ ultracode: true, advisorModel: 'fable' });
});
it('keeps --effort beside it for a regular level', () => {
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'max', undefined, null, 'sonnet');
expect(args).toEqual(expect.arrayContaining(['--effort', 'max']));
expect(settingsOf(args)).toEqual({ advisorModel: 'sonnet' });
});
it('is unchanged without an advisor', () => {
expect(buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null, undefined)).toEqual(
buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null)
);
});
});
/**
* #514 (per-session `model` → `--model`) and #530 landed together. A claude session carrying
* both must launch with `--model <id>` AND the advisor folded into the one `--settings` JSON,
* whatever the effort, on the tmux template and on the direct-PTY fallback alike. Recovery of
* the same pair is pinned in test/session-model-recovery.test.ts.
*/
describe('a per-session model together with an advisor', () => {
const MODEL = 'claude-fable-5-1';
const cases = [
['ultracode', { ultracode: true, advisorModel: 'opus' }],
['high', { advisorModel: 'opus' }],
[undefined, { advisorModel: 'opus' }],
] as const;
it.each(cases)('tmux template carries --model and the merged --settings, effort %s', (effort, settings) => {
for (const resumeSessionId of [undefined, '11111111-2222-3333-4444-555555555555']) {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
model: MODEL,
effort,
advisorModel: 'opus',
resumeSessionId,
claudeCliVersion: null,
});
// The resume variant renders `resume || new`, so the model appears once per branch.
expect(cmd).toContain(`--model "${MODEL}"`);
expect(cmd).not.toContain('--advisor ');
const settingsFlags = cmd.match(/--settings /g) ?? [];
expect(settingsFlags.length).toBe(resumeSessionId ? 2 : 1);
expect(extractSettingsJson(cmd)).toEqual(settings);
if (effort === 'high') expect(cmd).toContain("--effort 'high'");
}
});
it('keeps the statusLine exporter in the same object beside the model', () => {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
model: MODEL,
effort: 'ultracode',
advisorModel: 'fable',
statusLineCommand: EXPORTER_CMD,
claudeCliVersion: null,
});
expect(cmd).toContain(`--model "${MODEL}"`);
expect(cmd.match(/--settings /g)).toHaveLength(1);
expect(extractSettingsJson(cmd)).toEqual({
ultracode: true,
advisorModel: 'fable',
statusLine: { type: 'command', command: EXPORTER_CMD },
});
});
it.each(cases)('direct-PTY args carry --model and the merged --settings, effort %s', (effort, settings) => {
const args = buildInteractiveArgs('sid', 'normal', MODEL, undefined, effort, undefined, null, 'opus');
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
if (effort === 'high') expect(args).toEqual(expect.arrayContaining(['--effort', 'high']));
});
describe('a real Session on the direct-PTY fallback', () => {
const live: Session[] = [];
afterEach(async () => {
for (const s of live.splice(0)) await s.stop();
ptySpawns.length = 0;
});
it.each(cases)('hands pty.spawn both, effort %s', async (effort, settings) => {
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: false,
model: MODEL,
advisorModel: 'opus',
effort,
});
live.push(session);
await session.startInteractive();
expect(ptySpawns).toHaveLength(1);
const { args } = ptySpawns[0];
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
});
});
});
describe('Session advisorModel', () => {
it('stores a valid advisor and persists it through toState()', () => {
const session = new Session({ workingDir: '/tmp', advisorModel: 'opus' });
expect(session.toState().advisorModel).toBe('opus');
});
it('drops an invalid value instead of forwarding it to the launch', () => {
expect(new Session({ workingDir: '/tmp', advisorModel: 'haiku' }).toState().advisorModel).toBeUndefined();
expect(new Session({ workingDir: '/tmp' }).toState().advisorModel).toBeUndefined();
});
});
describe('advisorModel request validation', () => {
it.each([
['CreateSessionSchema', CreateSessionSchema, { workingDir: '/tmp' }],
['QuickStartSchema', QuickStartSchema, {}],
['RalphLoopStartSchema', RalphLoopStartSchema, { taskDescription: 'x' }],
] as const)('%s accepts advisor models and rejects the rest', (_name, schema, base) => {
expect(schema.safeParse({ ...base, advisorModel: 'opus' }).success).toBe(true);
expect(schema.safeParse({ ...base, advisorModel: 'claude-fable-5-1' }).success).toBe(true);
expect(schema.safeParse({ ...base }).success).toBe(true);
expect(schema.safeParse({ ...base, advisorModel: 'haiku' }).success).toBe(false);
expect(schema.safeParse({ ...base, advisorModel: "opus'" }).success).toBe(false);
});
it('SettingsUpdateSchema takes claudeAdvisorModel, with "" meaning the CLI default', () => {
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: '' }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'fable' }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'haiku' }).success).toBe(false);
});
});
+37
View File
@@ -0,0 +1,37 @@
// @vitest-environment node
// capabilities.newline: the bytes Shift+Enter types into a CLI's pane. Data in the registry, not
// a branch on the CLI id (test/cli-registry-no-id-branching.test.ts keeps the latter true).
import { describe, expect, it } from 'vitest';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
describe('capabilities.newline', () => {
it('no stock CLI declares a chord: every one keeps the line feed', () => {
// codex 0.147.0 takes a line feed (checked against a real tmux pane), so there is no CLI that
// needs esc-enter yet. The capability exists for a user clis.json override and the next CLI.
const declared = STOCK_CLIS.filter((e) => e.capabilities.newline).map((e) => e.id as string);
expect(declared).toEqual([]);
});
it.each(['line-feed', 'esc-enter'])('schema accepts %s', (value) => {
const e = claude();
(e.capabilities as Record<string, unknown>).newline = value;
expect(CliEntrySchema.safeParse(e).success).toBe(true);
});
it.each(['lf', 'crlf', '\x1b\r', '', 0])('schema rejects %j (no free-form byte strings in config)', (value) => {
const e = claude();
(e.capabilities as Record<string, unknown>).newline = value;
expect(CliEntrySchema.safeParse(e).success).toBe(false);
});
it('is optional, so an entry that declares nothing keeps the line feed', () => {
const e = claude();
delete (e.capabilities as Record<string, unknown>).newline;
expect(CliEntrySchema.safeParse(e).success).toBe(true);
});
});
+27
View File
@@ -24,6 +24,7 @@
import { describe, it, expect } from 'vitest';
import { getCli } from '../src/config/cli-registry/registry.js';
import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
/** A fixed session id, so `--session-id` is stable across runs. */
const SID = '0f9c2b14-1111-2222-3333-444455556666';
@@ -54,6 +55,20 @@ describe('claude', () => {
);
});
it('renders a model as the quoted value of --model, even one that opens with a dash', () => {
// POST /api/sessions refuses a leading '-' in `model`, but the registry's `model-claude`
// pattern still admits one, so the builder must stay safe on its own: the value lands
// quoted, and Claude's option parser takes the word after `--model` as its value whatever
// it starts with, so it can never become a flag of its own.
expect(claude({ model: 'claude-fable-5-1' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --model "claude-fable-5-1"'
);
expect(claude({ model: '--dangerously-skip-permissions' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--model "--dangerously-skip-permissions"'
);
});
it('resumes through a shell fallback to a fresh session', () => {
// The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell
// text. This pin is what proves the fallback chain still renders as one command line.
@@ -131,6 +146,18 @@ describe('codex', () => {
it('resumes with a POSITIONAL subcommand, not a flag', () => {
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
});
it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => {
for (const level of CODEX_REASONING_EFFORTS) {
expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`);
}
});
it('keeps reasoning effort ahead of the resume subcommand', () => {
expect(cx({ model: 'gpt-5', reasoningEffort: 'high', resumeSessionId: 'roll_42' })).toBe(
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
);
});
});
describe('gemini', () => {
@@ -0,0 +1,41 @@
/**
* @fileoverview `codexConfig.reasoningEffort` on the create routes.
*
* The level becomes part of a `--config model_reasoning_effort=<level>` launch token, so the
* schema admits only the words codex knows; anything else fails the request rather than
* reaching the argv.
*/
import { describe, it, expect } from 'vitest';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
describe('codexConfig.reasoningEffort', () => {
it('accepts every level codex knows on both create routes', () => {
for (const level of CODEX_REASONING_EFFORTS) {
const created = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'codex',
codexConfig: { reasoningEffort: level },
});
expect(created.codexConfig?.reasoningEffort).toBe(level);
const quick = QuickStartSchema.parse({
caseName: 'work',
mode: 'codex',
codexConfig: { reasoningEffort: level },
});
expect(quick.codexConfig?.reasoningEffort).toBe(level);
}
});
it('rejects a level codex does not know, and anything shaped like shell, on both create routes', () => {
for (const reasoningEffort of ['bogus', 'HIGH', 'high; rm -rf /', '']) {
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'codex', codexConfig: { reasoningEffort } })
).toThrow();
expect(() =>
QuickStartSchema.parse({ caseName: 'work', mode: 'codex', codexConfig: { reasoningEffort } })
).toThrow();
}
});
});
+219
View File
@@ -0,0 +1,219 @@
/**
* @fileoverview The header-stats styles (`headerStatsStyle`, Discussion #426
* option G): 'classic' (as before), 'compact' (one system pill with sparklines
* plus a plan-ring pill) and 'tiles' (label over value, bar underneath; the
* default).
*
* Pinned here:
* - The default is 'tiles', and only the three known values are honoured.
* - The two clustered styles move the connection indicator INTO the system
* stats pill and the plan chip right after it, on the REAL header markup
* from index.html, and 'classic' puts both back exactly where the template
* had them.
* - The WS readout never disappears with a hidden System Stats pill.
* - The parts only the new styles draw (sparklines, rings, meters, tile words)
* are rendered with sane values and hidden by default in CSS, which is what
* keeps 'classic' looking exactly as before.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const INDEX = read('index.html');
const HEADER = INDEX.slice(INDEX.indexOf('<header class="header">'), INDEX.indexOf('</header>') + '</header>'.length);
let CodemanApp: { prototype: Record<string, any> };
let window: any;
let document: Document;
beforeAll(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
window = dom.window;
document = window.document;
window.setInterval = () => 0;
window.requestAnimationFrame = () => 0;
window.eval(
'var MobileDetection = { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => "desktop" }, ' +
'KeyboardHandler = {}, SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('app.js') +
'\n' +
read('settings-ui.js') +
'\n' +
read('panels-ui.js') +
'\n;window.__HeaderStatsCodemanApp = CodemanApp;'
);
CodemanApp = window.__HeaderStatsCodemanApp;
});
function makeApp() {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
app.$ = (id: string) => document.getElementById(id);
return app;
}
/** Element children of the header's right side, by id (or first class). */
const rightSide = () =>
[...document.getElementById('headerRight')!.children].map((el) => el.id || el.classList[0]);
const statsChildren = () =>
[...document.getElementById('headerSystemStats')!.children].map((el) => el.id || el.classList[0]);
beforeEach(() => {
document.body.innerHTML = HEADER;
delete document.documentElement.dataset.headerStats;
});
describe('resolveHeaderStatsStyle', () => {
it("defaults to 'tiles' and honours only the three known styles", () => {
const app = makeApp();
expect(app.resolveHeaderStatsStyle({})).toBe('tiles');
expect(app.resolveHeaderStatsStyle(undefined)).toBe('tiles');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'classic' })).toBe('classic');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'compact' })).toBe('compact');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'tiles' })).toBe('tiles');
expect(app.resolveHeaderStatsStyle({ headerStatsStyle: 'neon' })).toBe('tiles');
});
});
describe('applyHeaderStatsStyle', () => {
it('clusters WS, the system stats and the plan chip for tiles and compact', () => {
for (const style of ['tiles', 'compact']) {
document.body.innerHTML = HEADER;
const app = makeApp();
app.applyHeaderStatsStyle({ style, showSystemStats: true });
expect(document.documentElement.dataset.headerStats).toBe(style);
expect(statsChildren()[0]).toBe('connectionIndicator');
const right = rightSide();
expect(right[right.indexOf('headerSystemStats') + 1]).toBe('planUsageChip');
expect(right).not.toContain('connectionIndicator');
}
});
it("'classic' puts both back exactly where the template had them", () => {
const template = rightSide();
const app = makeApp();
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
app.applyHeaderStatsStyle({ style: 'compact', showSystemStats: true });
app.applyHeaderStatsStyle({ style: 'classic', showSystemStats: true });
expect(rightSide()).toEqual(template);
expect(statsChildren()).not.toContain('connectionIndicator');
expect(document.documentElement.dataset.headerStats).toBe('classic');
});
it('keeps WS out of a hidden System Stats pill', () => {
const template = rightSide();
const app = makeApp();
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: false });
expect(statsChildren()).not.toContain('connectionIndicator');
expect(rightSide().indexOf('connectionIndicator')).toBe(template.indexOf('connectionIndicator'));
});
it('is idempotent: re-applying the same style moves nothing', () => {
const app = makeApp();
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
const once = document.getElementById('headerRight')!.innerHTML;
app.applyHeaderStatsStyle({ style: 'tiles', showSystemStats: true });
expect(document.getElementById('headerRight')!.innerHTML).toBe(once);
});
});
describe('the parts the new styles draw', () => {
it('gives every plan window a ring and a meter with a clamped fill, the label the real number', () => {
const app = makeApp();
app.updatePlanUsageChip({ fiveHour: { usedPercentage: 28 }, sevenDay: { usedPercentage: 104 } });
const wins = [...document.querySelectorAll<HTMLElement>('#planUsageChip .pu-win')];
expect(wins).toHaveLength(2);
expect(wins[0].querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('28');
expect(wins[0].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('28%');
expect(wins[0].querySelector('.pu-ring')!.classList.contains('pu-green')).toBe(true);
expect(wins[1].querySelector('.pu-val')!.textContent).toBe('104%');
expect(wins[1].querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('100%');
expect(wins[1].querySelector('.pu-meter')!.classList.contains('pu-red')).toBe(true);
});
it('keeps an idle Claude window as a dimmed slot with an empty ring and meter', () => {
const app = makeApp();
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 35 } });
const idle = document.querySelector<HTMLElement>('#planUsageChip .pu-win-idle')!;
expect(idle.querySelector('.pu-val')!.textContent).toBe('—');
expect(idle.querySelector<HTMLElement>('.pu-ring')!.style.getPropertyValue('--pu')).toBe('0');
expect(idle.querySelector<HTMLElement>('.pu-meter > i')!.style.width).toBe('0%');
});
it('names the connection state in two short words per dot state', () => {
const app = makeApp();
const words = (dotClass: string, text = '') => app._connectionTileWords({ dotClass, text });
expect(words('connection-dot connected', 'WS')).toMatchObject({ label: 'WS', value: 'live', state: 'connected' });
expect(words('connection-dot fallback', 'HTTP')).toMatchObject({ label: 'HTTP', value: 'fallback' });
expect(words('connection-dot offline', 'Offline')).toMatchObject({ label: 'NET', value: 'offline' });
expect(words('connection-dot draining', 'Sending...')).toMatchObject({ label: 'SEND', value: 'queued' });
expect(words('connection-dot reconnecting', 'WS…')).toMatchObject({ label: 'WS', value: 'retry' });
expect(words('connection-dot reconnecting', 'Reconnecting...')).toMatchObject({ label: 'SSE', value: 'retry' });
});
it('writes the tile words beside the classic text, which stays as it was', () => {
const app = makeApp();
app._computeConnectionDescriptor = () => ({
display: 'flex',
dotClass: 'connection-dot connected',
text: 'WS · 2.0KB queued',
title: 'Terminal connected over WebSocket',
});
app._updateConnectionIndicator();
expect(document.getElementById('connectionText')!.textContent).toBe('WS · 2.0KB queued');
expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS');
expect(document.getElementById('connectionTileValue')!.textContent).toBe('live');
expect(document.getElementById('connectionTileValue')!.className).toBe('connection-tile-value connected');
});
it('draws the sparkline newest-right, with a 10% floor and only real samples', () => {
const app = makeApp();
const bars = () =>
[...document.querySelectorAll<HTMLElement>('#statCpuSpark > i')].map((bar) => bar.style.height);
app._pushStatSpark('cpu', 40);
expect(bars()).toEqual(['', '', '', '40%']);
app._pushStatSpark('cpu', 2);
app._pushStatSpark('cpu', 250);
app._pushStatSpark('cpu', 60);
app._pushStatSpark('cpu', 70);
expect(bars()).toEqual(['10%', '100%', '60%', '70%']);
});
});
describe('header stats wiring (static)', () => {
const css = read('styles.css');
it('hides the new-style parts by default, so classic looks exactly as before', () => {
expect(css).toMatch(
/\.stat-spark,\s*\.connection-tile-label,\s*\.connection-tile-value,\s*\.header-plan-usage \.pu-ring,\s*\.header-plan-usage \.pu-meter \{\s*display: none;/
);
});
it('stamps data-header-stats before first paint, tiles by default and classic on narrow screens', () => {
expect(INDEX).toContain(
"dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='compact')?H:'tiles'"
);
});
it('offers the three styles with tiles marked as the default', () => {
expect(INDEX).toMatch(
/<select id="appSettingsHeaderStatsStyle"[^>]*>\s*<option value="classic">[^<]+<\/option>\s*<option value="compact">[^<]+<\/option>\s*<option value="tiles">Tiles \(default\)<\/option>/
);
});
});
+22 -1
View File
@@ -5,7 +5,7 @@
* hook definitions for desktop notifications.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
import {
chmodSync,
closeSync,
@@ -26,6 +26,7 @@ import { spawn } from 'node:child_process';
import {
applyStatusLineConfig,
ensureCodemanHooks,
ensureStatusLineExporterScript,
findEffectiveUserStatusLineCommand,
generateBackgroundWakeScript,
generateHooksConfig,
@@ -1418,6 +1419,26 @@ describe('resolveStatusLineCliCommand', () => {
expect(siblings).toEqual([]);
});
it('survives concurrent refreshes in the same millisecond (sessions created at once)', async () => {
const scriptPath = (await resolveStatusLineCliCommand(testDir, true))!;
writeFileSync(scriptPath, '#!/bin/sh\n# CODEMAN_STATUSLINE_EXPORTER_V0\necho stale\n');
// A frozen clock makes every writer agree on the timestamp, which is what two
// session creates in one millisecond do. A temp name built from pid + Date.now()
// is then shared: the first rename consumes it, every later rename fails ENOENT,
// and that session's tmux create fell back to a direct PTY.
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
try {
const paths = await Promise.all(Array.from({ length: 8 }, () => ensureStatusLineExporterScript()));
expect(new Set(paths)).toEqual(new Set([scriptPath]));
} finally {
clock.mockRestore();
}
expect(readFileSync(scriptPath, 'utf-8')).not.toContain('echo stale');
expect(statSync(scriptPath).mode & 0o111).not.toBe(0);
const siblings = readdirSync(join(scriptPath, '..')).filter((f) => f.startsWith('statusline-exporter.sh.'));
expect(siblings).toEqual([]);
});
it('never overrides a real, hand-authored statusLine', async () => {
const claudeDir = join(testDir, '.claude');
mkdirSync(claudeDir, { recursive: true });
+117
View File
@@ -0,0 +1,117 @@
/** @fileoverview Settings → Terminal & Input → Key tester, driven with real keystrokes in Chromium. */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3194;
describe('Key tester in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(() => (window as any).app.openAppSettings());
await page.focus('#keyTesterInput');
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
const log = () => page.evaluate(() => document.getElementById('keyTesterLog')!.textContent ?? '');
it('shows keydown, keypress and keyup for Shift+Enter, with the modifier and charCode', async () => {
await page.keyboard.press('Shift+Enter');
const text = await log();
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=shift/);
// The keypress is the event that used to leak a bare \r to the PTY.
expect(text).toMatch(/keypress\s+key="Enter" code=Enter mods=shift charCode=13/);
expect(text).toMatch(/keyup\s+key="Enter" code=Enter mods=shift/);
});
it('shows Ctrl+Enter without a keypress, as xterm would never see one for Ctrl', async () => {
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
await page.keyboard.press('Control+Enter');
const text = await log();
expect(text).toMatch(/keydown\s+key="Enter" code=Enter mods=ctrl/);
expect(text).toMatch(/keyup/);
// Chromium emits no keypress for a Ctrl chord, which is why only Shift+Enter ever leaked a \r.
expect(text).not.toMatch(/keypress/);
});
it('lets no app shortcut fire for keys pressed in the field (Ctrl+W, Ctrl+L, Escape, Alt+1, Ctrl+K)', async () => {
// The shortcut dispatcher is a capture-phase document listener, so without a guard it ran before
// the field's own handler: Ctrl+W killed the active session, Ctrl+L cleared the terminal and
// Escape closed Settings, while this row says nothing is sent to a session.
await page.evaluate(() => {
const app = (window as any).app;
const calls: string[] = [];
(window as any).__calls = calls;
for (const name of ['killActiveSession', 'clearTerminal', 'openCommandPalette', 'closeAllPanels']) {
app[name] = (...args: unknown[]) => void calls.push(name + args.length);
}
});
await page.focus('#keyTesterInput');
// [chord, what the tester must report for it]; checked one at a time because the log keeps 14 lines.
const chords: [string, RegExp][] = [
['Control+W', /key="w" code=KeyW mods=ctrl/i],
['Control+L', /key="l" code=KeyL mods=ctrl/i],
['Escape', /key="Escape" code=Escape/],
['Alt+1', /code=Digit1 mods=alt/],
['Control+K', /key="k" code=KeyK mods=ctrl/i],
];
for (const [chord, seen] of chords) {
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
await page.keyboard.press(chord);
expect(await log(), chord).toMatch(seen);
expect(await page.evaluate(() => (window as any).__calls), chord).toEqual([]);
}
expect(await page.evaluate(() => document.getElementById('appSettingsModal')!.classList.contains('active'))).toBe(
true
);
});
it('still lets the shortcut fire anywhere else (the guard is scoped to data-raw-keys)', async () => {
await page.evaluate(() => {
(window as any).__calls.length = 0;
(document.activeElement as HTMLElement | null)?.blur();
});
await page.keyboard.press('Escape');
expect(await page.evaluate(() => (window as any).__calls)).toContain('closeAllPanels0');
});
it('keeps only the last 14 lines and never types into the field', async () => {
// The test above blurred the field, so focus it again: without this the presses land on
// <body>, the log keeps whatever the earlier tests left, and the cap is never exercised.
await page.focus('#keyTesterInput');
expect(await page.evaluate(() => document.activeElement?.id)).toBe('keyTesterInput');
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
const lines = async () => (await log()).split('\n');
// A printable key fires keydown, keypress and keyup, so 2 presses are 6 lines: under the cap
// the log accumulates rather than showing only the latest event.
for (let i = 0; i < 2; i++) await page.keyboard.press('a');
expect(await lines()).toHaveLength(6);
// A different key, so eviction is visible: 4 x 3 = 12 more lines makes 18, capped to 14. The
// oldest 4 go (all of the first 'a' press and the second one's keydown), the newest stay in order.
for (let i = 0; i < 4; i++) await page.keyboard.press('b');
const capped = await lines();
expect(capped).toHaveLength(14);
expect(capped.filter((l) => l.includes('key="a"'))).toHaveLength(2);
expect(capped.filter((l) => l.includes('key="b"'))).toHaveLength(12);
expect(capped[0]).toMatch(/^keypress\s+key="a" code=KeyA mods=none charCode=97$/);
expect(capped[13]).toMatch(/^keyup\s+key="b" code=KeyB mods=none$/);
// Readonly: none of those presses typed anything into the field itself.
expect(await page.inputValue('#keyTesterInput')).toBe('');
});
});
+76
View File
@@ -0,0 +1,76 @@
// @vitest-environment node
// The registry half of MCP sync: which CLIs declare an MCP config file, and that the schema
// guards the path (sync writes to it) so a user clis.json cannot aim a write outside $HOME.
import { describe, expect, it } from 'vitest';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
function withMcp(mcpConfig: unknown) {
const e = claude();
(e.capabilities as Record<string, unknown>).mcpConfig = mcpConfig;
return CliEntrySchema.safeParse(e);
}
describe('capabilities.mcpConfig', () => {
it('is declared by exactly the CLIs whose format is verified', () => {
const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string);
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode']);
});
it('every stock declaration passes the schema, with a distinct file per CLI', () => {
for (const e of STOCK_CLIS) expect(CliEntrySchema.safeParse(e).success, e.id as string).toBe(true);
const paths = STOCK_CLIS.flatMap((e) => (e.capabilities.mcpConfig ? [e.capabilities.mcpConfig.path] : []));
expect(new Set(paths).size).toBe(paths.length);
});
it('accepts a home-relative path with a known format', () => {
expect(withMcp({ path: '.tool/mcp.json', format: 'claude-json' }).success).toBe(true);
});
it("declares each CLI's own relocation env var, and none for antigravity (HOME only)", () => {
const reloc = Object.fromEntries(
STOCK_CLIS.flatMap((e) =>
e.capabilities.mcpConfig ? [[e.id as string, e.capabilities.mcpConfig.relocation]] : []
)
);
expect(reloc).toEqual({
claude: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
opencode: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
codex: { envVar: 'CODEX_HOME', path: 'config.toml' },
gemini: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
antigravity: undefined,
});
});
it('accepts a relocation with an env var name and a relative path', () => {
const value = { path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: 'mcp.json' } };
expect(withMcp(value).success).toBe(true);
});
it.each([
['parent traversal', { path: '../evil.json', format: 'claude-json' }],
['nested traversal', { path: '.a/../../evil.json', format: 'claude-json' }],
['absolute path', { path: '/etc/cron.d/x', format: 'claude-json' }],
['shell metacharacters', { path: '.a;rm -rf', format: 'claude-json' }],
['unknown format', { path: '.a/mcp.json', format: 'yaml' }],
['extra key', { path: '.a/mcp.json', format: 'claude-json', mode: 'rw' }],
[
'relocation path traversal',
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '../x.json' } },
],
[
'relocation absolute path',
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'A_HOME', path: '/etc/x.json' } },
],
[
'relocation env var that is not a name',
{ path: '.a/mcp.json', format: 'claude-json', relocation: { envVar: 'a-home', path: 'x.json' } },
],
])('rejects %s', (_label, value) => {
expect(withMcp(value).success).toBe(false);
});
});
+587
View File
@@ -0,0 +1,587 @@
// @vitest-environment node
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parse as parseToml } from 'smol-toml';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { addServers, McpSyncBusyError, parseServers, syncMcpServers, type McpSyncTarget } from '../src/mcp-sync.js';
const TARGETS: McpSyncTarget[] = [
{ id: 'claude', label: 'Claude', path: '.claude.json', format: 'claude-json', installed: true },
{ id: 'gemini', label: 'Gemini', path: '.gemini/settings.json', format: 'gemini-json', installed: true },
{ id: 'codex', label: 'Codex', path: '.codex/config.toml', format: 'codex-toml', installed: true },
{
id: 'antigravity',
label: 'Antigravity',
path: '.gemini/config/mcp_config.json',
format: 'antigravity-json',
installed: true,
},
{
id: 'opencode',
label: 'OpenCode',
path: '.config/opencode/opencode.json',
format: 'opencode-json',
installed: true,
},
];
let home: string;
const put = (rel: string, text: string) => {
const file = join(home, rel);
mkdirSync(join(file, '..'), { recursive: true });
writeFileSync(file, text);
};
const get = (rel: string) => readFileSync(join(home, rel), 'utf8');
const target = (id: string, patch: Partial<McpSyncTarget> = {}) => ({
...TARGETS.find((t) => t.id === id)!,
...patch,
});
const only = (...ids: string[]) => TARGETS.filter((t) => ids.includes(t.id));
const result = (r: Awaited<ReturnType<typeof syncMcpServers>>, id: string) => r.targets.find((t) => t.id === id)!;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mcp-sync-'));
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
describe('dialect parsing', () => {
it('reads codex TOML tables, inline tables and multi-line arrays', () => {
const servers = parseServers(
'codex-toml',
[
'model = "gpt-5"',
'',
'[mcp_servers.fs]',
'command = "npx"',
'args = [',
' "-y", # comment',
' "@mcp/fs",',
']',
'env = { TOKEN = "abc" }',
'',
'[mcp_servers."a.b".env]',
'K = "v"',
'',
'[mcp_servers."a.b"]',
'command = "x"',
'',
'[mcp_servers.web]',
'url = "https://x.test/mcp"',
'[mcp_servers.web.http_headers]',
'Authorization = "Bearer t"',
].join('\n')
);
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { TOKEN: 'abc' } });
expect(servers['a.b']).toEqual({ transport: 'stdio', command: 'x', env: { K: 'v' } });
expect(servers.web).toEqual({
transport: 'http',
url: 'https://x.test/mcp',
headers: { Authorization: 'Bearer t' },
});
});
it('reads CRLF codex files (the old offset math saw no servers at all)', () => {
const servers = parseServers('codex-toml', '[mcp_servers.fs]\r\ncommand = "npx"\r\nargs = ["-y"]\r\n');
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y'] });
});
it('reads gemini url (sse) vs httpUrl / type http, and opencode local/remote', () => {
const g = parseServers(
'gemini-json',
JSON.stringify({
mcpServers: {
a: { url: 'https://a' },
b: { httpUrl: 'https://b' },
c: { command: 'c', args: ['1'] },
d: { url: 'https://d', type: 'http' },
},
})
);
expect(g.a.transport).toBe('sse');
expect(g.b.transport).toBe('http');
expect(g.c).toEqual({ transport: 'stdio', command: 'c', args: ['1'] });
expect(g.d.transport).toBe('http');
const o = parseServers(
'opencode-json',
JSON.stringify({
mcp: {
l: { type: 'local', command: ['npx', '-y', 'x'], environment: { A: '1' } },
r: { type: 'remote', url: 'https://r' },
},
})
);
expect(o.l).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', 'x'], env: { A: '1' } });
expect(o.r).toEqual({ transport: 'http', url: 'https://r' });
});
it('throws on unparseable files so they are never written', () => {
expect(() => parseServers('opencode-json', '{ // jsonc\n}')).toThrow();
expect(() => parseServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\ncommand="y"\n')).toThrow();
});
});
describe('real CLI output (captured from `agy`/`gemini`/`codex mcp add`)', () => {
it('reads and writes the antigravity dialect', () => {
const real = JSON.stringify({
mcpServers: {
fs: { args: ['-y', '@mcp/fs'], command: 'npx', disabled: false, env: { K: 'v' } },
web: { disabled: false, headers: { Authorization: 'Bearer T' }, serverUrl: 'https://x.test/mcp' },
},
});
const servers = parseServers('antigravity-json', real);
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } });
expect(servers.web).toEqual({
transport: 'http',
url: 'https://x.test/mcp',
headers: { Authorization: 'Bearer T' },
});
const out = JSON.parse(
addServers('antigravity-json', null, { ...servers, s: { transport: 'sse', url: 'https://s' } })
);
expect(out.mcpServers.web.serverUrl).toBe('https://x.test/mcp');
expect(out.mcpServers.fs.disabled).toBe(false);
expect(out.mcpServers.s).toBeUndefined();
});
it('writes gemini http/sse as url + type, as `gemini mcp add` does', () => {
const out = JSON.parse(
addServers('gemini-json', null, {
web: { transport: 'http', url: 'https://x.test/mcp', headers: { A: 'b' } },
s: { transport: 'sse', url: 'https://x.test/sse' },
})
);
expect(out.mcpServers.web).toEqual({ url: 'https://x.test/mcp', type: 'http', headers: { A: 'b' } });
expect(out.mcpServers.s).toEqual({ url: 'https://x.test/sse', type: 'sse' });
expect(parseServers('gemini-json', JSON.stringify(out)).web.transport).toBe('http');
});
it('reads codex output as written by `codex mcp add`', () => {
const real =
'[mcp_servers.fs]\ncommand = "npx"\nargs = ["-y", "@mcp/fs"]\n\n[mcp_servers.fs.env]\nK = "v"\n\n[mcp_servers.web]\nurl = "https://x.test/mcp"\n';
const servers = parseServers('codex-toml', real);
expect(servers.fs).toEqual({ transport: 'stdio', command: 'npx', args: ['-y', '@mcp/fs'], env: { K: 'v' } });
expect(servers.web).toEqual({ transport: 'http', url: 'https://x.test/mcp' });
});
});
describe('hostile config files', () => {
it('never lets a server name or a sub-table key reach Object.prototype', () => {
const toml = parseServers(
'codex-toml',
[
'[mcp_servers.__proto__]',
'command = "x"',
'polluted = "yes"',
'[mcp_servers.fs.__proto__]',
'polluted = "yes"',
'[mcp_servers.fs]',
'command = "y"',
'[mcp_servers.toString]',
'command = "t"',
'call = "x"',
].join('\n')
);
expect(Object.keys(toml).sort()).toEqual(['fs', 'toString']);
const json = parseServers(
'claude-json',
'{"mcpServers":{"__proto__":{"command":"x"},"constructor":{"command":"x"},"hasOwnProperty":{"command":"h"},"ok":{"command":"y"}}}'
);
expect(Object.keys(json).sort()).toEqual(['hasOwnProperty', 'ok']);
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
expect(({} as Record<string, unknown>).command).toBeUndefined();
expect(typeof Object.prototype.toString.call).toBe('function');
});
it('treats servers named like Object.prototype members as ordinary names across CLIs', async () => {
put(
'.claude.json',
JSON.stringify({ mcpServers: { toString: { command: 'a' }, hasOwnProperty: { command: 'b' } } })
);
put('.gemini/settings.json', JSON.stringify({ mcpServers: {} }));
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
expect(r.conflicts).toEqual([]);
expect(result(r, 'gemini').added.sort()).toEqual(['hasOwnProperty', 'toString']);
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual([
'hasOwnProperty',
'toString',
]);
});
it('rejects a non-object server table instead of overwriting it', () => {
expect(() => parseServers('claude-json', '{"mcpServers":[]}')).toThrow();
expect(() => parseServers('claude-json', '[]')).toThrow();
});
});
describe('addServers', () => {
it('preserves other keys and existing servers', () => {
const out = JSON.parse(
addServers('claude-json', JSON.stringify({ theme: 'dark', mcpServers: { keep: { command: 'k' } } }), {
keep: { transport: 'stdio', command: 'OVERWRITE' },
n: { transport: 'stdio', command: 'n' },
})
);
expect(out.theme).toBe('dark');
expect(out.mcpServers.keep).toEqual({ command: 'k' });
expect(out.mcpServers.n.command).toBe('n');
});
it('appends codex tables without touching the rest, and quotes odd names', () => {
const toml = addServers('codex-toml', 'model = "x"\n', {
'we ird': { transport: 'stdio', command: 'c', args: ['a"b'], env: { K: 'v' } },
});
expect(toml.startsWith('model = "x"\n')).toBe(true);
expect(parseServers('codex-toml', toml)['we ird']).toEqual({
transport: 'stdio',
command: 'c',
args: ['a"b'],
env: { K: 'v' },
});
});
it.each([
['CRLF line endings', '[mcp_servers.fs]\r\ncommand = "npx"\r\n'],
['an [mcp_servers] table with inline tables', '[mcp_servers]\nfs = { command = "npx" }\n'],
['a table with neither command nor url', '[mcp_servers.fs]\nstartup_timeout_sec = 30\n'],
])('never appends a second [mcp_servers.fs] to a codex file with %s', (_label, existing) => {
const out = addServers('codex-toml', existing, {
fs: { transport: 'stdio', command: 'other' },
extra: { transport: 'stdio', command: 'e' },
});
// Still valid TOML (a duplicate header would throw), fs untouched, extra added.
const doc = parseToml(out) as { mcp_servers: Record<string, Record<string, unknown>> };
expect(Object.keys(doc.mcp_servers).sort()).toEqual(['extra', 'fs']);
expect(doc.mcp_servers.fs.command === 'other').toBe(false);
expect(out.startsWith(existing)).toBe(true);
if (existing.includes('\r\n')) expect(out.replace(/\r\n/g, '')).not.toContain('\n');
});
it('refuses to write when the result would not read back as intended', () => {
// A name TOML cannot carry as a bare key still round-trips (quoted); a duplicate cannot.
expect(() => addServers('codex-toml', '[mcp_servers.a]\ncommand="x"\n[mcp_servers.a]\n', {})).toThrow();
});
});
describe('disabled servers are not propagated', () => {
it('codex enabled=false, opencode enabled:false and antigravity disabled:true stay where they are', async () => {
put('.codex/config.toml', '[mcp_servers.off_codex]\ncommand = "a"\nenabled = false\n');
put(
'.config/opencode/opencode.json',
JSON.stringify({ mcp: { off_oc: { type: 'local', command: ['b'], enabled: false } } })
);
put(
'.gemini/config/mcp_config.json',
JSON.stringify({ mcpServers: { off_agy: { command: 'c', disabled: true } } })
);
put('.claude.json', JSON.stringify({ mcpServers: { live: { type: 'stdio', command: 'l' } } }));
mkdirSync(join(home, '.gemini'), { recursive: true });
put('.gemini/settings.json', '{}');
const r = await syncMcpServers(TARGETS, { apply: true, home });
expect(r.disabled).toEqual(['off_agy', 'off_codex', 'off_oc']);
expect(Object.keys(JSON.parse(get('.claude.json')).mcpServers)).toEqual(['live']);
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers)).toEqual(['live']);
// ...and each CLI still gets the live one.
expect(get('.codex/config.toml')).toContain('[mcp_servers.live]');
expect(get('.codex/config.toml')).not.toContain('off_oc');
// The switched-off entry itself is left as it was (still disabled).
expect(get('.codex/config.toml')).toContain('enabled = false');
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.off_oc.enabled).toBe(false);
expect(JSON.parse(get('.gemini/config/mcp_config.json')).mcpServers.off_agy.disabled).toBe(true);
});
it('a name switched off in one CLI and live in another is still synced from the live one', async () => {
put('.codex/config.toml', '[mcp_servers.fs]\ncommand = "a"\nenabled = false\n');
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'a' } } }));
put('.gemini/settings.json', '{}');
const r = await syncMcpServers(only('claude', 'codex', 'gemini'), { apply: true, home });
expect(r.disabled).toEqual([]);
expect(result(r, 'gemini').added).toEqual(['fs']);
});
});
describe('syncMcpServers', () => {
const claudeFile = JSON.stringify({
numStartups: 3,
mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'], env: { T: 's3cret' } } },
});
const setUpAll = () => {
for (const d of ['.gemini/config', '.codex', '.config/opencode']) mkdirSync(join(home, d), { recursive: true });
put('.gemini/settings.json', '{}');
put('.codex/config.toml', '');
put('.config/opencode/opencode.json', '{}');
put('.gemini/config/mcp_config.json', '{}');
};
it('previews without writing and never leaks env values', async () => {
setUpAll();
put('.claude.json', claudeFile);
const before = get('.gemini/settings.json');
const r = await syncMcpServers(TARGETS, { apply: false, home });
expect(r.applied).toBe(false);
expect(result(r, 'gemini').added).toEqual(['fs']);
expect(get('.gemini/settings.json')).toBe(before);
expect(JSON.stringify(r)).not.toContain('s3cret');
});
it('adds missing servers to every other CLI, keeps a backup, is idempotent', async () => {
setUpAll();
put('.claude.json', claudeFile);
put('.codex/config.toml', 'model = "gpt-5"\n[mcp_servers.web]\nurl = "https://w"\n');
const r = await syncMcpServers(TARGETS, { apply: true, home });
expect(result(r, 'claude').added).toEqual(['web']);
expect(result(r, 'codex').added).toEqual(['fs']);
expect(JSON.parse(get('.claude.json')).numStartups).toBe(3);
expect(Object.keys(JSON.parse(get('.gemini/settings.json')).mcpServers).sort()).toEqual(['fs', 'web']);
expect(JSON.parse(get('.config/opencode/opencode.json')).mcp.fs.command).toEqual(['npx', '-y', 'fs']);
expect(get('.codex/config.toml')).toContain('model = "gpt-5"');
expect(existsSync(join(home, '.claude.json.codeman-bak'))).toBe(true);
const again = await syncMcpServers(TARGETS, { apply: true, home });
expect(again.targets.every((t) => t.added.length === 0)).toBe(true);
});
it('reports conflicts without overwriting, skips what a dialect cannot express, leaves unreadable files alone', async () => {
setUpAll();
put(
'.claude.json',
JSON.stringify({ mcpServers: { x: { command: 'one' }, sse: { type: 'sse', url: 'https://s' } } })
);
put('.gemini/settings.json', JSON.stringify({ mcpServers: { x: { command: 'two' } } }));
const broken = '{ // jsonc\n "mcp": {} }';
put('.config/opencode/opencode.json', broken);
const r = await syncMcpServers(TARGETS, { apply: true, home });
expect(r.conflicts).toEqual(['x']);
expect(JSON.parse(get('.gemini/settings.json')).mcpServers.x.command).toBe('two');
expect(result(r, 'codex').skipped).toEqual(['sse']);
expect(result(r, 'opencode').status).toBe('unreadable');
expect(get('.config/opencode/opencode.json')).toBe(broken);
});
it('passes the unsupported list through to the result', async () => {
const r = await syncMcpServers(TARGETS, { apply: false, home }, ['Pi']);
expect(r.unsupported).toEqual(['Pi']);
});
describe('only CLIs that are installed or already have a config file take part', () => {
it('never creates config for a CLI that is neither installed nor configured', async () => {
put('.claude.json', claudeFile);
const notInstalled = TARGETS.map((t) => (t.id === 'claude' ? t : { ...t, installed: false }));
const r = await syncMcpServers(notInstalled, { apply: true, home });
for (const id of ['gemini', 'codex', 'antigravity', 'opencode']) expect(result(r, id).status).toBe('absent');
expect(existsSync(join(home, '.codex'))).toBe(false);
expect(existsSync(join(home, '.gemini'))).toBe(false);
expect(existsSync(join(home, '.config'))).toBe(false);
});
it('a CLI that is not detected as installed still takes part if its config file exists', async () => {
put('.claude.json', claudeFile);
put('.gemini/settings.json', '{}');
const r = await syncMcpServers([target('claude'), target('gemini', { installed: false })], { apply: true, home });
expect(result(r, 'gemini').added).toEqual(['fs']);
});
it('an installed CLI with no config yet gets one created', async () => {
put('.claude.json', claudeFile);
const r = await syncMcpServers([target('claude'), target('codex')], { apply: true, home });
expect(result(r, 'codex').added).toEqual(['fs']);
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
});
});
describe('file safety', () => {
it('leaves a file that receives env values or headers readable by its owner only', async () => {
put('.claude.json', claudeFile);
put('.gemini/settings.json', '{}');
chmodSync(join(home, '.gemini/settings.json'), 0o664);
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o600);
});
it('keeps the existing mode when nothing secret is copied', async () => {
put('.claude.json', JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx' } } }));
put('.gemini/settings.json', '{}');
chmodSync(join(home, '.gemini/settings.json'), 0o664);
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
expect(statSync(join(home, '.gemini/settings.json')).mode & 0o777).toBe(0o664);
});
it('writes through a symlinked config instead of replacing the link', async () => {
put('.claude.json', claudeFile);
mkdirSync(join(home, 'dotfiles'), { recursive: true });
writeFileSync(join(home, 'dotfiles/gemini-settings.json'), '{}');
mkdirSync(join(home, '.gemini'), { recursive: true });
symlinkSync(join(home, 'dotfiles/gemini-settings.json'), join(home, '.gemini/settings.json'));
await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
expect(lstatSync(join(home, '.gemini/settings.json')).isSymbolicLink()).toBe(true);
expect(JSON.parse(readFileSync(join(home, 'dotfiles/gemini-settings.json'), 'utf8')).mcpServers.fs.command).toBe(
'npx'
);
expect(existsSync(join(home, 'dotfiles/gemini-settings.json.codeman-bak'))).toBe(true);
});
it('reports a dangling symlink as failed and writes nothing', async () => {
put('.claude.json', claudeFile);
mkdirSync(join(home, '.gemini'), { recursive: true });
symlinkSync(join(home, 'nowhere.json'), join(home, '.gemini/settings.json'));
const r = await syncMcpServers(only('claude', 'gemini'), { apply: true, home });
expect(result(r, 'gemini').status).toBe('failed');
expect(existsSync(join(home, 'nowhere.json'))).toBe(false);
});
it('refuses a second apply while one is running, and leaves no temp files behind', async () => {
put('.claude.json', claudeFile);
put('.gemini/settings.json', '{}');
const first = syncMcpServers(only('claude', 'gemini'), { apply: true, home });
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).rejects.toBeInstanceOf(
McpSyncBusyError
);
await first;
// A preview is read-only and is never refused.
await expect(syncMcpServers(only('claude', 'gemini'), { apply: false, home })).resolves.toBeDefined();
// ...and the lock is released afterwards.
await expect(syncMcpServers(only('claude', 'gemini'), { apply: true, home })).resolves.toBeDefined();
const leftovers = readdirSync(join(home, '.gemini')).filter((f) => f.includes('codeman-tmp'));
expect(leftovers).toEqual([]);
});
});
});
describe('error messages never quote the config file (it holds env values and headers)', () => {
const claudeWithSecret = JSON.stringify({
mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sk-COPIED-SECRET' } } },
});
it('reports an unparseable TOML file by line and column only', async () => {
put('.claude.json', claudeWithSecret);
put(
'.codex/config.toml',
'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n'
);
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
const codex = result(r, 'codex');
expect(codex.status).toBe('unreadable');
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
expect(JSON.stringify(r)).not.toContain('lin_SECRET_abc');
expect(JSON.stringify(r)).not.toContain('LINEAR_API_KEY');
});
it('reports an unparseable JSON file by position, or by category when V8 quotes source instead', async () => {
put('.claude.json', claudeWithSecret);
// V8: `Unexpected token 's', ..."TOKEN":sk-GEMINI-SECRET}"... is not valid JSON` (no position).
put('.gemini/settings.json', '{"mcpServers":{"g":{"command":"x","env":{"TOKEN":sk-GEMINI-SECRET}}}}');
// V8: `Expected ',' or '}' after property value in JSON at position N (line 2 column M)`.
put('.gemini/config/mcp_config.json', '{\n "mcpServers": {"a": {"env": {"K": "sk-AGY-SECRET" "x"}}}\n}');
const r = await syncMcpServers(only('claude', 'gemini', 'antigravity'), { apply: false, home });
expect(result(r, 'gemini').status).toBe('unreadable');
expect(result(r, 'gemini').error).toBe('not valid JSON');
expect(result(r, 'antigravity').error).toMatch(/^not valid JSON \(line 2, column \d+\)$/);
const body = JSON.stringify(r);
for (const secret of ['sk-GEMINI-SECRET', 'sk-AGY-SECRET', 'TOKEN']) expect(body).not.toContain(secret);
});
it('a write refused at the re-parse quotes neither the file nor the copied server', async () => {
put('.claude.json', claudeWithSecret);
// An inline top-level table parses, but appending `[mcp_servers.fs]` to it does not.
const inline = 'mcp_servers = { a = { command = "x", env = { K = "sk-FILE-SECRET" } } }\n';
put('.codex/config.toml', inline);
const r = await syncMcpServers(only('claude', 'codex'), { apply: true, home });
const codex = result(r, 'codex');
expect(codex.status).toBe('failed');
expect(codex.error).toMatch(/^not valid TOML \(line \d+, column \d+\)$/);
expect(get('.codex/config.toml')).toBe(inline);
const body = JSON.stringify(r);
expect(body).not.toContain('sk-FILE-SECRET');
expect(body).not.toContain('sk-COPIED-SECRET');
});
});
describe('relocated config dirs (the CLI reads its file somewhere else)', () => {
const claudeFile = JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } });
const CODEX_RELOC = { relocation: { envVar: 'CODEX_HOME', path: 'config.toml' } };
const CLAUDE_RELOC = { relocation: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' } };
const OPENCODE_RELOC = { relocation: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' } };
it('writes $CODEX_HOME/config.toml, never the default ~/.codex/config.toml', async () => {
put('.claude.json', claudeFile);
const codexHome = join(home, 'elsewhere/codex');
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
apply: true,
home,
env: { CODEX_HOME: codexHome },
});
expect(result(r, 'codex').file).toBe(join(codexHome, 'config.toml'));
expect(result(r, 'codex').added).toEqual(['fs']);
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
expect(existsSync(join(home, '.codex'))).toBe(false);
});
it('reads the source from $CLAUDE_CONFIG_DIR and writes $XDG_CONFIG_HOME/opencode', async () => {
const claudeDir = join(home, 'accounts/work');
mkdirSync(claudeDir, { recursive: true });
writeFileSync(join(claudeDir, '.claude.json'), claudeFile);
// A default-location file that claude does NOT read under CLAUDE_CONFIG_DIR: its server must not spread.
put('.claude.json', JSON.stringify({ mcpServers: { stray: { type: 'stdio', command: 'nope' } } }));
const xdg = join(home, 'xdg');
const r = await syncMcpServers([target('claude', CLAUDE_RELOC), target('opencode', OPENCODE_RELOC)], {
apply: true,
home,
env: { CLAUDE_CONFIG_DIR: claudeDir, XDG_CONFIG_HOME: xdg },
});
expect(result(r, 'claude').servers).toEqual(['fs']);
expect(Object.keys(JSON.parse(readFileSync(join(xdg, 'opencode/opencode.json'), 'utf8')).mcp)).toEqual(['fs']);
expect(existsSync(join(home, '.config'))).toBe(false);
});
it('reports a relative relocation value as skipped and writes nothing anywhere', async () => {
put('.claude.json', claudeFile);
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
apply: true,
home,
env: { CODEX_HOME: 'relative/codex' },
});
const codex = result(r, 'codex');
expect(codex.status).toBe('skipped');
expect(codex.error).toMatch(/CODEX_HOME is set to a relative path/);
expect(codex.added).toEqual([]);
expect(existsSync(join(home, '.codex'))).toBe(false);
expect(existsSync(join(process.cwd(), 'relative'))).toBe(false);
});
it('an empty value means unset, as it does for the CLI', async () => {
put('.claude.json', claudeFile);
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], {
apply: true,
home,
env: { CODEX_HOME: '' },
});
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
expect(get('.codex/config.toml')).toContain('[mcp_servers.fs]');
});
it("ignores the caller's own env when home is overridden and no env is passed", async () => {
put('.claude.json', claudeFile);
const saved = process.env.CODEX_HOME;
process.env.CODEX_HOME = join(home, 'from-process-env');
try {
const r = await syncMcpServers([target('claude'), target('codex', CODEX_RELOC)], { apply: true, home });
expect(result(r, 'codex').file).toBe(join(home, '.codex/config.toml'));
expect(existsSync(join(home, 'from-process-env'))).toBe(false);
} finally {
if (saved === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = saved;
}
});
});
+722
View File
@@ -0,0 +1,722 @@
/**
* @fileoverview Wiring tests for the iOS IME preview (mobile-ime-preview.js).
*
* The controller itself is covered by test/mobile-ime-preview.test.ts. These
* pin how terminal-ui.js and the delivery graph consume it: script order,
* build registration and CSS, the _init/_destroyMobileImePreview lifecycle,
* the onData routing of an IME commit, and the rule that only output accepted
* AFTER a commit may clear the committed preview.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
const indexSource = read('src/web/public/index.html');
const buildSource = read('scripts/build.mjs');
const terminalSource = read('src/web/public/terminal-ui.js');
const cssSource = read('src/web/public/styles.css');
type Fn = ReturnType<typeof vi.fn>;
type App = Record<string, any>;
function loadMixin(globals: Record<string, unknown> = {}) {
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
const windowStub = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
...(globals.window as object),
} as Record<string, unknown>;
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
cancelAnimationFrame: vi.fn(),
URLSearchParams,
location: { search: '' },
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
document: { addEventListener: vi.fn(), createElement: vi.fn() },
MobileDetection: { isTouchDevice: () => false },
CodemanApp: FakeCodemanApp,
_crashDiag: { log: vi.fn() },
...globals,
window: windowStub,
});
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
return { mixin: FakeCodemanApp.prototype, context, windowStub };
}
function fakeClassList() {
const values = new Set<string>();
return {
add: vi.fn((value: string) => values.add(value)),
remove: vi.fn((value: string) => values.delete(value)),
toggle: vi.fn((value: string, force?: boolean) => {
const enabled = force === undefined ? !values.has(value) : force;
if (enabled) values.add(value);
else values.delete(value);
return enabled;
}),
contains: (value: string) => values.has(value),
};
}
function createPreviewHarness(
options: {
eligible?: boolean;
createThrows?: boolean;
omitGlobal?: boolean;
themeForeground?: string;
themeBackground?: string;
themeGetterThrows?: boolean;
} = {}
) {
const compositionView = {
style: {
fontFamily: '"Fira Code"',
fontSize: '10px',
fontWeight: '500',
fontStyle: 'italic',
lineHeight: '12px',
height: '12px',
color: 'rgb(255, 255, 255)',
},
};
const helpers = {
classList: fakeClassList(),
children: [] as Array<Record<string, unknown>>,
querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null),
appendChild(node: Record<string, unknown>) {
this.children.push(node);
},
};
const createdControllers: Array<Record<string, any>> = [];
const previewNodes: Array<Record<string, any>> = [];
const documentStub = {
addEventListener: vi.fn(),
createElement: vi.fn(() => {
const node = {
className: '',
hidden: false,
textContent: '',
dataset: {} as Record<string, string>,
style: {} as Record<string, string>,
attributes: {} as Record<string, string>,
setAttribute(name: string, value: string) {
this.attributes[name] = value;
},
remove: vi.fn(),
};
previewNodes.push(node);
return node;
}),
};
const mobileImePreview = options.omitGlobal
? undefined
: {
isIosWebKitTouch: vi.fn(() => options.eligible ?? true),
create: vi.fn((callbacks: Record<string, unknown>) => {
if (options.createThrows) throw new Error('controller unavailable');
const controller = {
destroy: vi.fn(),
reset: vi.fn(),
consumeTerminalData: vi.fn(() => false),
completeCommit: vi.fn(),
noteAuthoritativeOutput: vi.fn(),
callbacks,
};
createdControllers.push(controller);
return controller;
}),
};
const { mixin, windowStub } = loadMixin({
document: documentStub,
MobileImePreview: mobileImePreview,
getComputedStyle: (node: { style: Record<string, string> }) => node.style,
});
windowStub.codemanCurrentXtermTheme = () => {
if (options.themeGetterThrows) throw new Error('theme unavailable');
return { foreground: '#334455', background: '#223344' };
};
const app: App = Object.assign(Object.create(mixin), {
terminal: {
textarea: {},
options: {
theme:
options.themeForeground || options.themeBackground
? { foreground: options.themeForeground, background: options.themeBackground }
: undefined,
},
element: { querySelector: (selector: string) => (selector === '.xterm-helpers' ? helpers : null) },
},
activeSessionId: 'session-a',
});
return { app, helpers, compositionView, previewNodes, createdControllers, mobileImePreview, windowStub };
}
describe('mobile IME preview delivery graph', () => {
it('loads the controller after xterm and before terminal wiring', () => {
const at = indexSource.indexOf('<script defer src="mobile-ime-preview.js"></script>');
expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js'));
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="terminal-ui.js"></script>'));
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="app.js"></script>'));
});
it('is minified and content-hashed by the build', () => {
expect(buildSource).toContain("run('minify mobile-ime-preview.js'");
expect(buildSource).toMatch(/const HASHABLE = \[[^\]]*'mobile-ime-preview\.js'/);
});
it('scopes preview presentation and native composition suppression to touch ownership', () => {
expect(cssSource).toContain('.xterm-helpers .codeman-ime-preview {');
expect(cssSource).toContain(".xterm-helpers .codeman-ime-preview[data-phase='provisional'] {");
expect(cssSource).toContain('.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {');
const rule = cssSource.slice(cssSource.indexOf('.xterm-helpers .codeman-ime-preview {'));
expect(rule.slice(0, rule.indexOf('}'))).toContain('left: var(--xterm-helper-left, 0px)');
expect(rule.slice(0, rule.indexOf('}'))).toContain('top: var(--xterm-helper-top, 0px)');
});
it('initializes the preview right after the terminal opens', () => {
expect(terminalSource).toMatch(/this\.terminal\.open\(container\);\s*this\._initMobileImePreview\(\);/);
});
});
describe('mobile IME preview lifecycle', () => {
it('fails open when the global is absent or create throws', () => {
for (const options of [{ omitGlobal: true }, { createThrows: true }]) {
const { app } = createPreviewHarness(options);
expect(() => app._initMobileImePreview()).not.toThrow();
expect(app._mobileImePreview).toBeNull();
}
});
it('does not create a controller on unsupported input platforms', () => {
const { app, mobileImePreview, previewNodes } = createPreviewHarness({ eligible: false });
app._initMobileImePreview();
expect(mobileImePreview?.create).not.toHaveBeenCalled();
expect(previewNodes).toHaveLength(0);
expect(app._mobileImePreview).toBeNull();
});
it('creates one controller bound to the terminal textarea and one hidden preview node', () => {
const { app, helpers, previewNodes, mobileImePreview } = createPreviewHarness();
app._initMobileImePreview();
expect(mobileImePreview?.create).toHaveBeenCalledOnce();
expect(mobileImePreview?.create.mock.calls[0][0].textarea).toBe(app.terminal.textarea);
// Keydown is observed on the terminal element (an ancestor of the
// textarea), so it runs before xterm's own capture listener finalizes.
expect(mobileImePreview?.create.mock.calls[0][0].keydownTarget).toBe(app.terminal.element);
expect(helpers.children).toEqual([previewNodes[0]]);
expect(previewNodes[0]).toMatchObject({ className: 'codeman-ime-preview', hidden: true });
expect(previewNodes[0].attributes['aria-hidden']).toBe('true');
});
it('destroys prior ownership on repeated initialization and keeps one active controller', () => {
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
app._initMobileImePreview();
const first = createdControllers[0];
app._initMobileImePreview();
expect(first.destroy).toHaveBeenCalledOnce();
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
expect(createdControllers).toHaveLength(2);
expect(app._mobileImePreview).toBe(createdControllers[1]);
// Window listeners are released with the controller that owned them.
expect((windowStub.removeEventListener as Fn).mock.calls.map((call) => call[0]).sort()).toEqual([
'offline',
'pagehide',
]);
});
it('destroy releases the controller, the node and the listeners', () => {
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
app._initMobileImePreview();
app._destroyMobileImePreview();
expect(createdControllers[0].destroy).toHaveBeenCalledOnce();
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
expect(app._mobileImePreview).toBeNull();
expect(windowStub.removeEventListener).toHaveBeenCalledTimes(2);
});
it('pagehide resets the controller instead of destroying it, so a bfcache restore keeps the preview', () => {
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
app._initMobileImePreview();
const pagehide = (windowStub.addEventListener as Fn).mock.calls.find((call) => call[0] === 'pagehide')?.[1];
expect(pagehide).toBeTypeOf('function');
pagehide();
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
expect(createdControllers[0].destroy).not.toHaveBeenCalled();
expect(previewNodes[0].remove).not.toHaveBeenCalled();
expect(app._mobileImePreview).toBe(createdControllers[0]);
// A second hide after the page came back from the cache still works.
pagehide();
expect(createdControllers[0].reset).toHaveBeenCalledTimes(2);
});
it('resets the controller exactly once when the active session changes', () => {
const { app, createdControllers } = createPreviewHarness();
app._initMobileImePreview();
app.activeSessionId = 'session-b';
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false });
app.sessions = new Map();
app._updateLocalEchoState();
app._updateLocalEchoState();
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
});
it('renders and clears owned preview state', () => {
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
app._initMobileImePreview();
const callbacks = createdControllers[0].callbacks;
callbacks.render({ text: '你好', phase: 'provisional' });
expect(previewNodes[0]).toMatchObject({ textContent: '你好', hidden: false, dataset: { phase: 'provisional' } });
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
callbacks.clear();
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true, dataset: {} });
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
});
// With local echo on, typed text sits in the overlay (z-index 7) and the PTY
// cursor that places the span stays at the prompt start, under that text. The
// overlay draws the composition instead. Real-xterm proof of the covering:
// test/mobile-ime-preview.browser.test.ts.
function withOverlay(app: App, visible = true) {
const overlay = {
composition: '',
setComposition: vi.fn(function (this: { composition: string }, text: string) {
this.composition = text;
}),
state: { visible },
};
Object.assign(app, { _localEchoEnabled: true, _localEchoOverlay: overlay });
return overlay;
}
it('routes the preview into the local echo overlay when local echo is on', () => {
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
const overlay = withOverlay(app);
app._initMobileImePreview();
const callbacks = createdControllers[0].callbacks;
callbacks.render({ text: '天気', phase: 'provisional' });
expect(overlay.setComposition).toHaveBeenLastCalledWith('天気');
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true });
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
callbacks.clear();
expect(overlay.setComposition).toHaveBeenLastCalledWith('');
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
});
it('uses the span when the overlay cannot place the composition (no prompt found)', () => {
const { app, previewNodes, createdControllers } = createPreviewHarness();
const overlay = withOverlay(app, false);
app._initMobileImePreview();
createdControllers[0].callbacks.render({ text: '天気', phase: 'provisional' });
expect(overlay.composition).toBe('');
expect(previewNodes[0]).toMatchObject({ textContent: '天気', hidden: false });
});
it('uses the span, not the overlay, when local echo is off or handed back to PTY echo', () => {
const off = createPreviewHarness();
const offOverlay = withOverlay(off.app);
Object.assign(off.app, { _localEchoEnabled: false });
off.app._initMobileImePreview();
off.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
expect(offOverlay.setComposition).not.toHaveBeenCalled();
expect(off.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
const passthrough = createPreviewHarness();
const passOverlay = withOverlay(passthrough.app);
Object.assign(passthrough.app, { _echoPassthroughSessions: new Set(['session-a']) });
passthrough.app._initMobileImePreview();
passthrough.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
expect(passOverlay.setComposition).not.toHaveBeenCalled();
expect(passthrough.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
});
it('uses the terminal foreground and opaque background while mirroring native composition font metrics', () => {
const { app, compositionView, previewNodes, createdControllers } = createPreviewHarness({
themeForeground: '#1f2328',
themeBackground: '#fafafa',
});
app._initMobileImePreview();
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
expect(previewNodes[0].style).toMatchObject({
fontFamily: compositionView.style.fontFamily,
fontSize: compositionView.style.fontSize,
fontWeight: compositionView.style.fontWeight,
fontStyle: compositionView.style.fontStyle,
lineHeight: compositionView.style.lineHeight,
height: compositionView.style.height,
color: '#1f2328',
backgroundColor: '#fafafa',
});
});
it('falls back to the current skin theme when the terminal options carry no theme', () => {
const { app, previewNodes, createdControllers } = createPreviewHarness();
app._initMobileImePreview();
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
expect(previewNodes[0].style).toMatchObject({ color: '#334455', backgroundColor: '#223344' });
});
it('keeps rendering with a safe foreground when the theme getter throws', () => {
const { app, previewNodes, createdControllers } = createPreviewHarness({ themeGetterThrows: true });
app._initMobileImePreview();
expect(() => createdControllers[0].callbacks.render({ text: '安全', phase: 'provisional' })).not.toThrow();
expect(previewNodes[0].textContent).toBe('安全');
expect(previewNodes[0].style.color).toBe('#e0e0e0');
expect(previewNodes[0].style.backgroundColor).toBe('#0d0d0d');
});
it.each(['query', 'create', 'append', 'className', 'hidden'] as const)(
'removes partial DOM ownership when %s fails',
(failure) => {
const removed = vi.fn();
const owner = fakeClassList();
const preview = new Proxy(
{ dataset: {}, remove: removed, setAttribute: vi.fn() },
{
set(target, property, value) {
if (property === failure) throw new Error(`${failure} failed`);
return Reflect.set(target, property, value);
},
}
);
const helpers = {
classList: owner,
appendChild:
failure === 'append'
? () => {
throw new Error('append failed');
}
: vi.fn(),
};
const documentStub = {
addEventListener: vi.fn(),
createElement:
failure === 'create'
? () => {
throw new Error('create failed');
}
: () => preview,
};
const MobileImePreview = { isIosWebKitTouch: () => true, create: vi.fn() };
const { mixin } = loadMixin({ document: documentStub, MobileImePreview });
const app: App = Object.assign(Object.create(mixin), {
terminal: {
textarea: {},
element: {
querySelector:
failure === 'query'
? () => {
throw new Error('query failed');
}
: () => helpers,
},
},
});
expect(() => app._initMobileImePreview()).not.toThrow();
expect(app._mobileImePreview).toBeNull();
expect(MobileImePreview.create).not.toHaveBeenCalled();
expect(owner.contains('codeman-ime-preview-owned')).toBe(false);
if (!['query', 'create'].includes(failure)) expect(removed).toHaveBeenCalled();
}
);
});
/**
* Rebuilds the real `handleTerminalData` closure from initTerminal's source,
* so these cases exercise the shipped routing rather than a copy of it.
*/
function loadHandleTerminalData(app: App, sent: string[]) {
const marker = 'const handleTerminalData = (data) => {';
const start = terminalSource.indexOf(marker);
if (start < 0) throw new Error('handleTerminalData definition not found');
const bodyStart = start + marker.length;
const end = terminalSource.indexOf('\n };', bodyStart);
if (end < 0) throw new Error('handleTerminalData boundary not found');
const timers: Array<() => void> = [];
app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data);
const context = vm.createContext({
console,
performance,
setTimeout: (callback: () => void) => {
timers.push(callback);
return timers.length;
},
clearTimeout: vi.fn(),
document: { activeElement: null, getElementById: vi.fn(() => null) },
window: {
cjkActive: false,
CodemanTerminalInput: {
BRACKETED_PASTE_START: '\x1b[200~',
shouldSuppressTerminalQueryResponse: () => false,
isTerminalFocusOrMouseReport: () => false,
isComposerNavKey: () => false,
},
},
_crashDiag: { log: vi.fn() },
flushInput: () => {
app._inputFlushTimeout = null;
if (app._pendingInput && app.activeSessionId) {
const input = app._pendingInput;
app._pendingInput = '';
app._sendInputAsync(app.activeSessionId, input);
}
},
});
const handler = vm.runInContext(`(function (data) {${terminalSource.slice(bodyStart, end)}\n})`, context) as (
this: App,
data: string
) => void;
return { handle: (data: string) => handler.call(app, data), timers };
}
describe('mobile IME commit onData routing', () => {
function onDataApp(options: {
localEcho: boolean;
tagged?: boolean;
overlayMissing?: boolean;
addThrows?: boolean;
appendThrows?: boolean;
consumeThrows?: boolean;
}) {
const { mixin } = loadMixin();
const sent: string[] = [];
const controller = {
consumeTerminalData: options.consumeThrows
? vi.fn(() => {
throw new Error('consume failed');
})
: vi
.fn()
.mockReturnValueOnce(options.tagged ?? true)
.mockReturnValue(false),
completeCommit: vi.fn(),
noteAuthoritativeOutput: vi.fn(),
};
const overlay = {
pendingText: '',
appendText: vi.fn((data: string) => {
if (options.appendThrows) throw new Error('overlay failed');
overlay.pendingText += data;
}),
addChar: vi.fn((data: string) => {
if (options.addThrows) throw new Error('overlay failed');
overlay.pendingText += data;
}),
clear: vi.fn(() => {
overlay.pendingText = '';
}),
suppressBufferDetection: vi.fn(),
};
const app: App = Object.assign(Object.create(mixin), {
activeSessionId: 'session-a',
_localEchoEnabled: options.localEcho,
_localEchoOverlay: options.overlayMissing ? null : overlay,
_echoPassthroughSessions: new Set(),
_flushedOffsets: new Map(),
_flushedTexts: new Map(),
_pendingInput: '',
_inputFlushTimeout: null,
_lastKeystrokeTime: 0,
_terminalOutputSeq: 5,
_mobileImePreview: controller,
});
return { app, controller, overlay, sent, ...loadHandleTerminalData(app, sent) };
}
it('moves a multi-character commit into local echo and submits it only on Enter', () => {
const { app, controller, overlay, sent, handle, timers } = onDataApp({ localEcho: true });
handle('你好');
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
expect(overlay.appendText).toHaveBeenCalledWith('你好');
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
expect(app._mobileImeCommitOutputSeq).toBeNull();
expect(sent).toEqual([]);
handle('\r');
expect(sent).toEqual(['你好']);
timers.shift()?.();
expect(sent).toEqual(['你好', '\r']);
});
it('moves a single-character commit into local echo through addChar', () => {
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true });
handle('界');
expect(overlay.addChar).toHaveBeenCalledWith('界');
expect(overlay.appendText).not.toHaveBeenCalled();
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
expect(sent).toEqual([]);
});
it.each([
['appendText throws', { appendThrows: true }, '失敗'],
['addChar throws', { addThrows: true }, '字'],
])('never sends a commit the overlay may already hold when %s', (_label, extra, text) => {
// One code path with typed text: no send-on-throw fallback, which would
// double-send if the overlay threw after appending.
const { controller, sent, handle } = onDataApp({ localEcho: true, ...extra });
expect(() => handle(text)).toThrow('overlay failed');
expect(sent).toEqual([]);
// Nothing shows the text, so the preview keeps it until its fallback.
expect(controller.completeCommit).not.toHaveBeenCalled();
});
it('keeps the preview when the overlay is missing, exactly like typed text', () => {
const { controller, sent, handle } = onDataApp({ localEcho: true, overlayMissing: true });
expect(() => handle('日本')).not.toThrow();
expect(sent).toEqual([]);
expect(controller.completeCommit).not.toHaveBeenCalled();
});
it('completes the commit only after the printable branch has put it in the overlay', () => {
const { controller, overlay, handle } = onDataApp({ localEcho: true });
controller.completeCommit.mockImplementation(() => {
expect(overlay.pendingText).toBe('界');
});
handle('界');
expect(controller.completeCommit).toHaveBeenCalledOnce();
});
it('keeps an untagged paste on the existing local echo path', () => {
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true, tagged: false });
handle('plain paste');
expect(overlay.pendingText).toBe('plain paste');
expect(controller.completeCommit).not.toHaveBeenCalled();
expect(sent).toEqual([]);
});
it('sends a commit once without local echo and holds the preview until output arrives', () => {
const { app, controller, sent, handle } = onDataApp({ localEcho: false });
handle('日本語');
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
expect(sent).toEqual(['日本語']);
expect(controller.completeCommit).not.toHaveBeenCalled();
expect(app._mobileImeCommitOutputSeq).toBe(5);
});
it('sends the original bytes exactly once when the controller throws', () => {
const { sent, handle } = onDataApp({ localEcho: false, consumeThrows: true });
expect(() => handle('你好')).not.toThrow();
expect(sent).toEqual(['你好']);
});
});
describe('mobile IME commit and authoritative terminal output', () => {
function outputHarness(mode = 'claude') {
const { mixin } = loadMixin();
const parses: Array<() => void> = [];
const written: string[] = [];
const controller = {
consumeTerminalData: vi.fn(() => true),
noteAuthoritativeOutput: vi.fn(),
};
const app: App = Object.assign(Object.create(mixin), {
pendingWrites: [],
terminal: {
rows: 24,
write: vi.fn((data: string, callback?: () => void) => {
written.push(data);
if (callback) parses.push(callback);
}),
buffer: { active: { viewportY: 0 } },
scrollToBottom: vi.fn(),
},
activeSessionId: 'session-a',
sessions: new Map([['session-a', { mode }]]),
isTerminalAtBottom: () => true,
_hasRecentUserScrollUp: () => false,
_safeYield: vi.fn(),
_localEchoOverlay: null,
_mobileImePreview: controller,
});
const flush = () => {
app.writeFrameScheduled = false;
app.flushPendingWrites();
};
const parseNext = () => parses.shift()?.();
return { app, controller, written, flush, parseNext };
}
it('clears the committed preview once output accepted after the commit is parsed', () => {
const { app, controller, flush, parseNext } = outputHarness();
app._consumeMobileImeTerminalData('你好');
app.batchTerminalWrite('echo');
flush();
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
parseNext();
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
});
it('does not let output queued before the commit clear it, even when it parses after', () => {
const { app, controller, flush, parseNext } = outputHarness();
app.batchTerminalWrite('before');
flush();
app._consumeMobileImeTerminalData('你好');
parseNext();
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
app.batchTerminalWrite('after');
flush();
parseNext();
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
});
it('does not let a split chunk clear the commit before its remainder is written', () => {
const { app, controller, written, flush, parseNext } = outputHarness('codex');
app._consumeMobileImeTerminalData('你好');
app.batchTerminalWrite('x'.repeat(40000));
flush();
parseNext();
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
flush();
parseNext();
expect(written.join('')).toBe('x'.repeat(40000));
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
});
it('does not let output parsed after a session switch clear the new session preview', () => {
const { app, controller, flush, parseNext } = outputHarness();
app._consumeMobileImeTerminalData('你好');
app.batchTerminalWrite('echo');
flush();
app.activeSessionId = 'session-b';
parseNext();
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
});
it('re-places an overlay that holds only a composition after output (hasPending is false there)', () => {
const { app, flush } = outputHarness();
const overlay = { hasPending: false, pendingText: '', composition: '今日', rerender: vi.fn() };
app._localEchoOverlay = overlay;
app.batchTerminalWrite('output that moves the prompt');
flush();
expect(overlay.rerender).toHaveBeenCalledOnce();
});
it('re-places it after a resize too: no rerender() site is gated on hasPending', () => {
// The resize observer is a closure inside initTerminal(), so it is pinned
// by source; the post-write follow runs against real xterm in
// test/mobile-ime-preview.browser.test.ts.
expect(terminalSource).toMatch(
/this\.updateConnectionLines\(\);\s*(?:\/\/[^\n]*\n\s*)*this\._localEchoOverlay\?\.rerender\(\);/
);
expect(terminalSource).not.toMatch(/hasPending\)\s*\{?\s*this\._localEchoOverlay\.rerender\(\)/);
});
it('notifies once per commit, never for later output', () => {
const { app, controller, flush, parseNext } = outputHarness();
app._consumeMobileImeTerminalData('你好');
for (const chunk of ['a', 'b']) {
app.batchTerminalWrite(chunk);
flush();
parseNext();
}
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
});
});
+392
View File
@@ -0,0 +1,392 @@
/**
* The iOS IME preview controller against a REAL xterm 6 instance.
*
* The controller's logic is unit-tested in test/mobile-ime-preview.test.ts
* with a stand-in for xterm. What only real xterm proves is the event ORDER:
* `terminal.open()` registers xterm's keydown listener in the capture phase on
* the helper textarea, and CompositionHelper.keydown finalizes a composition
* there and emits the commit through onData synchronously. The controller must
* observe that keydown first (capture phase on `terminal.element`), and must
* finalize on exactly the keys xterm does.
*
* No server: a blank page loads the vendored xterm bundle and the controller.
* Browser-driven, so it is excluded from `npm run test:ci` like the other
* Playwright suites. Run locally:
* npm run test:browser -- test/mobile-ime-preview.browser.test.ts
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { build } from 'esbuild';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const root = resolve(import.meta.dirname, '..');
type Step =
| ['start']
| ['update', string]
| ['end', string]
| ['key', number, string, boolean]
| ['wait', number]
| ['consume', string];
describe('mobile IME preview with real xterm', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.setContent('<div id="t" style="width:600px;height:300px"></div>');
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
});
async function drive(steps: Step[]) {
return page.evaluate(async (steps: Step[]) => {
const w = window as any;
const host = document.getElementById('t') as HTMLElement;
host.innerHTML = '';
const term = new w.Terminal();
term.open(host);
const textarea = term.textarea as HTMLTextAreaElement;
const renders: Array<{ text: string; phase: string }> = [];
const onData: Array<{ data: string; consumed: boolean }> = [];
const controller = w.MobileImePreview.create({
textarea,
keydownTarget: term.element,
render: (r: { text: string; phase: string }) => renders.push(r),
clear: () => {},
});
term.onData((data: string) => onData.push({ data, consumed: controller.consumeTerminalData(data) }));
textarea.focus();
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
for (const step of steps) {
if (step[0] === 'start') textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
if (step[0] === 'update') {
textarea.value = step[1];
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: step[1] }));
}
if (step[0] === 'end') textarea.dispatchEvent(new CompositionEvent('compositionend', { data: step[1] }));
if (step[0] === 'key') {
const [, keyCode, key, isComposing] = step;
const event = new KeyboardEvent('keydown', { key, isComposing, bubbles: true, cancelable: true });
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
textarea.dispatchEvent(event);
}
if (step[0] === 'wait') await tick(step[1]);
if (step[0] === 'consume') onData.push({ data: step[1], consumed: controller.consumeTerminalData(step[1]) });
}
await tick(20);
const { composing, awaitingCommit, committed, latest } = controller.state;
const result = { onData, state: { composing, awaitingCommit, committed, latest }, lastRender: renders.at(-1) };
controller.destroy();
term.dispose();
return result;
}, steps);
}
it('Enter mid-composition: xterm emits the commit and the controller takes it as committed', async () => {
// compositionupdate's textarea end offset is recorded by xterm on a 0 ms timer.
const result = await drive([['start'], ['update', '確定'], ['wait', 10], ['key', 13, 'Enter', false]]);
expect(result.onData).toEqual([
{ data: '確定', consumed: true },
{ data: '\r', consumed: false },
]);
expect(result.state).toMatchObject({ awaitingCommit: false, committed: true, latest: '確定' });
expect(result.lastRender).toEqual({ text: '確定', phase: 'committed' });
});
it('keyCode 229 with isComposing false: xterm keeps composing, so the preview keeps following', async () => {
const result = await drive([
['start'],
['update', 'か'],
['wait', 10],
['key', 229, 'k', false],
['update', 'かな'],
]);
expect(result.onData).toEqual([]);
expect(result.state).toMatchObject({ composing: true, awaitingCommit: false, latest: 'かな' });
expect(result.lastRender).toEqual({ text: 'かな', phase: 'provisional' });
});
it('a deleted composition stops waiting after 2 s, so a later paste is not taken as its commit', async () => {
const result = await drive([
['start'],
['update', 'abc'],
['update', ''],
['end', ''],
['wait', 2100],
['consume', 'pasted'],
]);
expect(result.onData).toEqual([{ data: 'pasted', consumed: false }]);
expect(result.state).toMatchObject({ awaitingCommit: false, committed: false });
});
});
/**
* The preview with local echo ON, the default for Claude sessions on phones.
* Committed text then sits in the LocalEchoOverlay (a z-index 7 layer in
* `.xterm-screen`) and never reaches the PTY before Enter, so the PTY cursor,
* which is where the helper span sits, stays at the prompt start: under the
* overlay's own opaque text. So a composition that follows text already in the
* overlay must be drawn by the overlay itself, after that text.
*
* Loads the real pieces: xterm 6, the overlay bundled from its package source
* exactly as scripts/postinstall.js bundles it (plus the same LocalEchoOverlay
* alias), styles.css, mobile-ime-preview.js, and terminal-ui.js's own
* `_initMobileImePreview` on a bare CodemanApp prototype.
*/
describe('mobile IME preview over the local echo overlay', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
const bundled = await build({
entryPoints: [resolve(root, 'packages/xterm-zerolag-input/src/zerolag-input-addon.ts')],
bundle: true,
format: 'iife',
globalName: 'XtermZerolagInput',
write: false,
logLevel: 'silent',
});
const overlayBundle =
bundled.outputFiles[0].text +
'\nwindow.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
'constructor(terminal){super({prompt:{type:"character",char:"\\u276f",offset:2}});this.activate(terminal);}};\n';
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 800, height: 400 }, deviceScaleFactor: 1 });
await page.setContent(
'<html class="touch-device"><body><div id="t" style="width:600px;height:240px"></div></body></html>'
);
await page.addStyleTag({ path: resolve(root, 'node_modules/@xterm/xterm/css/xterm.css') });
await page.addStyleTag({ content: readFileSync(resolve(root, 'src/web/public/styles.css'), 'utf8') });
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
await page.addScriptTag({ content: overlayBundle });
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' });
await page.addScriptTag({ path: resolve(root, 'src/web/public/terminal-ui.js') });
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
});
/**
* Types `pending` into the overlay (as the printable/paste branch does), then
* composes `composing` and reports what is PAINTED at the cell right after
* the pending text and at the PTY cursor. Painted = topmost by hit-testing
* with pointer-events forced on, since the overlay and the preview are
* pointer-events:none.
*/
async function composeAfter(pending: string, composing: string, commit: boolean) {
return page.evaluate(
async ({ pending, composing, commit }) => {
const w = window as any;
const host = document.getElementById('t') as HTMLElement;
host.innerHTML = '';
const term = new w.Terminal({
cols: 40,
rows: 8,
fontSize: 14,
fontFamily: 'monospace',
allowProposedApi: true,
});
term.open(host);
await new Promise<void>((r) => term.write('\u276f ', () => r()));
const app = new w.CodemanApp();
app.terminal = term;
app._localEchoEnabled = true;
app._localEchoOverlay = new w.LocalEchoOverlay(term);
w.MobileImePreview.isIosWebKitTouch = () => true;
app._initMobileImePreview();
// The helper textarea and span follow the PTY cursor (col 2, row 0), as
// _syncMobileHelperTextareaToCursor places them.
const screen = term.element.querySelector('.xterm-screen') as HTMLElement;
const dims = term._core._renderService.dimensions.css.cell;
term.element.style.setProperty('--xterm-helper-left', 2 * dims.width + 'px');
term.element.style.setProperty('--xterm-helper-top', '0px');
if (pending) app._localEchoOverlay.appendText(pending);
const textarea = term.textarea as HTMLTextAreaElement;
textarea.focus();
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
textarea.value = composing;
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
const force = document.createElement('style');
force.textContent = '.xterm * { pointer-events: auto !important; }';
document.head.appendChild(force);
const rect = screen.getBoundingClientRect();
const widthOf = (s: string) => term._core.unicodeService.getStringCellWidth(s);
const paintedAt = (col: number) => {
const el = document.elementFromPoint(
rect.left + (col + 0.5) * dims.width,
rect.top + 0.5 * dims.height
) as HTMLElement | null;
return {
text: el?.textContent ?? null,
composition: !!el?.closest?.('[data-zerolag-composition]'),
preview: !!el?.closest?.('.codeman-ime-preview'),
};
};
const afterPending = paintedAt(2 + widthOf(pending));
force.remove();
let afterCommit = null;
if (commit) {
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: composing }));
// What the printable/paste branch of terminal-ui.js's onData does.
if (app._consumeMobileImeTerminalData(composing)) {
app._localEchoOverlay.appendText(composing);
app._transferMobileImeCommitToLocalEcho();
}
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
afterCommit = {
pendingText: app._localEchoOverlay.pendingText,
compositionSpans: term.element.querySelectorAll('[data-zerolag-composition]').length,
overlayText: app._localEchoOverlay._overlay?.textContent,
};
}
const result = {
afterPending,
pendingText: app._localEchoOverlay.pendingText,
afterCommit,
};
app._destroyMobileImePreview();
app._localEchoOverlay.dispose();
term.dispose();
return result;
},
{ pending, composing, commit }
);
}
it('first composition on an empty prompt: the overlay draws it at the prompt', async () => {
const result = await composeAfter('', '今日は', false);
expect(result.afterPending).toEqual({ text: '今', composition: true, preview: false });
expect(result.pendingText).toBe('');
});
it('a second composition is painted after the text already in the overlay, not under it', async () => {
const result = await composeAfter('今日は', '天気', false);
expect(result.afterPending.text).toBe('天');
expect(result.afterPending.composition).toBe(true);
// Provisional text is never taken into the overlay's pending (unsent) text.
expect(result.pendingText).toBe('今日は');
});
it('the commit lands once in the overlay and the composition tail is gone', async () => {
const result = await composeAfter('今日は', '天気', true);
expect(result.afterCommit).toEqual({ pendingText: '今日は天気', compositionSpans: 0, overlayText: '今日は天気' });
});
/**
* Composes `composing` after `pending`, then streams output through the REAL
* write path (batchTerminalWrite, the scheduled flushPendingWrites, xterm's
* async parse) that moves the ❯ row from 0 to 3, then one more frame that
* leaves the prompt where it is (a status-line repaint). Reports the overlay
* row after each frame.
*
* The post-write re-place runs right after terminal.write() returns, before
* xterm parses that chunk, so it sees the buffer as of the previous frame: the
* overlay reaches the new row on the frame after the move. That timing is the
* same for pending text; the composition-only overlay used to never get there
* because the re-place was gated on hasPending, which excludes it.
*/
async function composeThenMovePrompt(pending: string, composing: string) {
return page.evaluate(
async ({ pending, composing }) => {
const w = window as any;
const host = document.getElementById('t') as HTMLElement;
host.innerHTML = '';
const term = new w.Terminal({
cols: 40,
rows: 8,
fontSize: 14,
fontFamily: 'monospace',
allowProposedApi: true,
});
term.open(host);
await new Promise<void>((r) => term.write('❯ ', () => r()));
const app = new w.CodemanApp();
Object.assign(app, {
terminal: term,
_localEchoEnabled: true,
_localEchoOverlay: new w.LocalEchoOverlay(term),
pendingWrites: [],
activeSessionId: 'session-a',
sessions: new Map([['session-a', { mode: 'claude' }]]),
});
w.MobileImePreview.isIosWebKitTouch = () => true;
app._initMobileImePreview();
if (pending) app._localEchoOverlay.appendText(pending);
const textarea = term.textarea as HTMLTextAreaElement;
textarea.focus();
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
textarea.value = composing;
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
const cellH = term._core._renderService.dimensions.css.cell.height;
const overlayEl = app._localEchoOverlay._overlay as HTMLElement;
const overlayRow = () =>
overlayEl.style.display === 'none' ? null : Math.round(parseFloat(overlayEl.style.top) / cellH);
// Output goes through the app's own scheduler; wait until it has been
// flushed and parsed.
const stream = async (data: string) => {
app.batchTerminalWrite(data);
for (let i = 0; i < 200; i++) {
if (!app.writeFrameScheduled && !app._terminalWriteInFlight && app.pendingWrites.length === 0) break;
await new Promise((r) => setTimeout(r, 10));
}
await new Promise<void>((r) => term.write('', () => r()));
};
const before = overlayRow();
await stream('\r\x1b[2Kline 1\r\nline 2\r\nline 3\r\n❯ ');
const promptRow = app._localEchoOverlay.findPrompt()?.row ?? null;
await stream('\x1b7\x1b[8;1Hworking\x1b8');
const result = {
before,
promptRow,
after: overlayRow(),
composition: Array.from(term.element.querySelectorAll('[data-zerolag-composition]'))
.map((el) => (el as HTMLElement).textContent)
.join(''),
hasPending: app._localEchoOverlay.hasPending,
};
app._destroyMobileImePreview();
app._localEchoOverlay.dispose();
term.dispose();
return result;
},
{ pending, composing }
);
}
it('a composition on an empty prompt follows the prompt when output moves it', async () => {
const result = await composeThenMovePrompt('', '今日');
expect(result.before).toBe(0);
expect(result.promptRow).toBe(3);
// Nothing is pending: before the fix this stayed on row 0, over "line 1".
expect(result.hasPending).toBe(false);
expect(result.after).toBe(3);
expect(result.composition).toBe('今日');
});
it('a composition after pending text follows it the same way', async () => {
const result = await composeThenMovePrompt('abc', '今日');
expect(result).toEqual({ before: 0, promptRow: 3, after: 3, composition: '今日', hasPending: true });
});
});
+672
View File
@@ -0,0 +1,672 @@
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
type Listener = (event: Record<string, unknown>) => void;
type ListenerOptions = boolean | { capture?: boolean };
type RegisteredListener = { listener: Listener; capture: boolean };
/**
* A DOM node with just enough event dispatch to reproduce listener ORDER: an
* ancestor's capture listeners, then the target's capture listeners, then the
* target's bubble listeners (at-target capture-first, as in Chromium 89+ and
* WebKit), then the ancestor's bubble listeners.
*/
class FakeNode {
parent: FakeNode | null = null;
protected listeners = new Map<string, RegisteredListener[]>();
addEventListener(type: string, listener: Listener, options?: ListenerOptions) {
const listeners = this.listeners.get(type) ?? [];
listeners.push({ listener, capture: options === true || options?.capture === true });
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener, options?: ListenerOptions) {
const capture = options === true || options?.capture === true;
const listeners = this.listeners.get(type) ?? [];
const index = listeners.findIndex(
(registered) => registered.listener === listener && registered.capture === capture
);
if (index >= 0) listeners.splice(index, 1);
}
run(type: string, capture: boolean, event: Record<string, unknown>) {
for (const registered of [...(this.listeners.get(type) ?? [])]) {
if (registered.capture === capture) registered.listener(event);
}
}
dispatch(type: string, event: Record<string, unknown> = {}) {
const full = { type, target: this, ...event };
const ancestors: FakeNode[] = [];
for (let node = this.parent; node; node = node.parent) ancestors.unshift(node);
for (const ancestor of ancestors) ancestor.run(type, true, full);
this.run(type, true, full);
this.run(type, false, full);
for (const ancestor of [...ancestors].reverse()) ancestor.run(type, false, full);
}
listenerCount() {
return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0);
}
}
class FakeTextarea extends FakeNode {
value = 'unchanged';
}
type Scheduled = { id: number; callback: () => void; delay?: number };
function harness(
overrides: Record<string, unknown> = {},
beforeCreate?: (textarea: FakeTextarea, getController: () => Record<string, any> | undefined) => void
) {
const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8');
const context = vm.createContext({ navigator: {} });
vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' });
const api = vm.runInContext('MobileImePreview', context);
// The terminal element: an ancestor of the helper textarea, like xterm's
// `.xterm` root is of `.xterm-helper-textarea`.
const element = new FakeNode();
const textarea = new FakeTextarea();
textarea.parent = element;
const frames: Scheduled[] = [];
const timers: Scheduled[] = [];
let nextId = 1;
const render = vi.fn();
const clear = vi.fn();
const onCommit = vi.fn();
const scheduleFrame = vi.fn((callback: () => void) => {
const id = nextId++;
frames.push({ id, callback });
return id;
});
const cancelFrame = vi.fn((id: number) => {
const index = frames.findIndex((frame) => frame.id === id);
if (index >= 0) frames.splice(index, 1);
});
const setTimer = vi.fn((callback: () => void, delay: number) => {
const id = nextId++;
timers.push({ id, callback, delay });
return id;
});
const clearTimer = vi.fn((id: number) => {
const index = timers.findIndex((timer) => timer.id === id);
if (index >= 0) timers.splice(index, 1);
});
let controller: Record<string, unknown> | undefined;
beforeCreate?.(textarea, () => controller);
controller = api.create({
textarea,
keydownTarget: element,
render,
clear,
onCommit,
scheduleFrame,
cancelFrame,
setTimer,
clearTimer,
...overrides,
});
const flushFrame = () => frames.shift()?.callback();
const flushTimer = () => timers.shift()?.callback();
return {
api,
element,
textarea,
frames,
timers,
render,
clear,
onCommit,
scheduleFrame,
cancelFrame,
setTimer,
clearTimer,
controller,
flushFrame,
flushTimer,
};
}
describe('MobileImePreview', () => {
beforeEach(() => vi.restoreAllMocks());
test('collapses 500 composition updates into one latest-state frame', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
for (let i = 0; i < 500; i += 1) h.textarea.dispatch('compositionupdate', { data: `value-${i}` });
expect(h.scheduleFrame).toHaveBeenCalledTimes(1);
expect(h.render).not.toHaveBeenCalled();
h.flushFrame();
expect(h.render).toHaveBeenCalledOnce();
expect(h.render).toHaveBeenLastCalledWith({ text: 'value-499', phase: 'provisional' });
expect(h.onCommit).not.toHaveBeenCalled();
expect(h.textarea.value).toBe('unchanged');
});
test('replaces provisional text for replacement, backspace, and composing input', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'abcdef' });
h.textarea.dispatch('compositionupdate', { data: 'xy' });
h.flushFrame();
expect(h.render).toHaveBeenLastCalledWith({ text: 'xy', phase: 'provisional' });
h.textarea.dispatch('input', { data: '', isComposing: true });
h.flushFrame();
expect(h.render).toHaveBeenLastCalledWith({ text: '', phase: 'provisional' });
expect(h.onCommit).not.toHaveBeenCalled();
});
test('caps the preview without changing the terminal handoff value', () => {
const h = harness();
const value = '界'.repeat(2050);
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: value });
h.flushFrame();
expect(h.render.mock.calls[0][0].text).toHaveLength(2048);
h.textarea.dispatch('compositionend', { data: value });
expect(h.controller.consumeTerminalData(value)).toBe(true);
expect(h.onCommit).toHaveBeenCalledWith(value);
});
test('hands off only the first safe xterm onData value after finalization', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: '日本語' });
h.textarea.dispatch('compositionend', { data: '日本語' });
expect(h.controller.consumeTerminalData('日本語')).toBe(true);
expect(h.controller.consumeTerminalData('日本語')).toBe(false);
expect(h.onCommit).toHaveBeenCalledOnce();
expect(h.render).not.toHaveBeenCalled();
expect(h.frames).toHaveLength(1);
h.flushFrame();
expect(h.render).toHaveBeenLastCalledWith({ text: '日本語', phase: 'committed' });
expect(h.setTimer).toHaveBeenCalledWith(expect.any(Function), 2000);
});
test('defers finalization rendering and updates the queued frame phase to committed', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'draft' });
h.textarea.dispatch('compositionend');
expect(h.render).not.toHaveBeenCalled();
expect(h.frames).toHaveLength(1);
expect(h.controller.consumeTerminalData('final')).toBe(true);
expect(h.frames).toHaveLength(1);
expect(h.render).not.toHaveBeenCalled();
h.flushFrame();
expect(h.render).toHaveBeenCalledOnce();
expect(h.render).toHaveBeenCalledWith({ text: 'final', phase: 'committed' });
});
test('treats the first safe xterm onData value as authoritative over stale provisional data', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'teh' });
h.textarea.dispatch('compositionend');
expect(h.controller.consumeTerminalData('the')).toBe(true);
expect(h.onCommit).toHaveBeenCalledOnce();
expect(h.onCommit).toHaveBeenCalledWith('the');
expect(h.controller.consumeTerminalData('teh')).toBe(false);
});
test.each(['', '\n', 'line\rbreak', 'two\nlines', '\u0003', '\u007f'])(
'rejects non-printable or multiline terminal data %j without consuming the pending value',
(rejected) => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionend', { data: rejected });
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
expect(h.onCommit).not.toHaveBeenCalled();
}
);
test.each(['line\u2028break', 'line\u2029break'])(
'rejects Unicode line separator terminal data %j without consuming the finalization fence',
(rejected) => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionend');
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
expect(h.controller.consumeTerminalData('safe')).toBe(true);
}
);
test('keydown can finalize composition before a late compositionend', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: '確定' });
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
expect(h.controller.consumeTerminalData('確定')).toBe(true);
h.textarea.dispatch('compositionend', { data: 'stale' });
expect(h.controller.consumeTerminalData('stale')).toBe(false);
});
/**
* Stand-in for xterm 6.0: `terminal.open()` registers a CAPTURE keydown
* listener on the helper textarea (CoreBrowserTerminal.ts:379), and
* CompositionHelper.keydown (CompositionHelper.ts:94-108) finalizes the
* composition there, emitting the commit through onData synchronously, for
* every keyCode except 20/229 and 16/17/18. It is registered BEFORE the
* controller is created, exactly as terminal.open() precedes
* _initMobileImePreview().
*/
function withXtermStandIn() {
const emitted: Array<{ data: string; consumed: boolean }> = [];
let composing = false;
let composition = '';
const h = harness({}, (textarea, getController) => {
const emit = (data: string) => emitted.push({ data, consumed: getController()?.consumeTerminalData(data) });
textarea.addEventListener('compositionstart', () => {
composing = true;
composition = '';
});
textarea.addEventListener('compositionupdate', (event) => {
composition = String(event.data ?? '');
});
textarea.addEventListener(
'keydown',
(event) => {
if (composing && ![20, 229, 16, 17, 18].includes(event.keyCode as number)) {
composing = false;
emit(composition);
}
if (event.keyCode === 13) emit('\r');
},
true
);
});
return { ...h, emitted, isXtermComposing: () => composing };
}
test('Enter mid-composition hands the commit xterm emits in its capture keydown to the preview', () => {
const h = withXtermStandIn();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: '確定' });
h.flushFrame();
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
expect(h.emitted).toEqual([
{ data: '確定', consumed: true },
{ data: '\r', consumed: false },
]);
expect(h.onCommit).toHaveBeenCalledWith('確定');
expect(h.controller.state).toMatchObject({ composing: false, awaitingCommit: false, committed: true });
h.flushFrame();
expect(h.render).toHaveBeenLastCalledWith({ text: '確定', phase: 'committed' });
// The next unrelated keystroke is ordinary input, not an IME commit.
expect(h.controller.consumeTerminalData('x')).toBe(false);
});
test.each([
// keyCode 229 with isComposing:false and a real key identity: xterm keeps
// composing, so the controller must too.
['the IME composition character', 'k', 229],
['CapsLock', 'CapsLock', 20],
['Shift', 'Shift', 16],
['Control', 'Control', 17],
['Alt', 'Alt', 18],
])('a keydown for %s keeps tracking the composition xterm is still composing', (_label, key, keyCode) => {
const h = withXtermStandIn();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'か' });
h.textarea.dispatch('keydown', { key, keyCode, isComposing: false });
expect(h.isXtermComposing()).toBe(true);
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
// The preview follows the composition instead of freezing on the old value.
h.textarea.dispatch('compositionupdate', { data: 'かな' });
h.flushFrame();
expect(h.render).toHaveBeenLastCalledWith({ text: 'かな', phase: 'provisional' });
expect(h.emitted).toEqual([]);
});
test('ignores keydowns that did not target the helper textarea', () => {
const h = harness();
const sibling = new FakeNode();
sibling.parent = h.element;
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: '漢字' });
sibling.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
});
test('destroy stops the controller observing keydown on the terminal element', () => {
const h = withXtermStandIn();
h.controller.destroy();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'later' });
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
expect(h.emitted).toEqual([
{ data: 'later', consumed: false },
{ data: '\r', consumed: false },
]);
expect(h.onCommit).not.toHaveBeenCalled();
expect(h.element.listenerCount()).toBe(0);
});
describe('a commit that never reaches onData', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
function realTimerHarness() {
return harness({
setTimer: (callback: () => void, delay: number) => setTimeout(callback, delay),
clearTimer: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
});
}
test.each([
['compositionend', (h: ReturnType<typeof harness>) => h.textarea.dispatch('compositionend', { data: '' })],
[
'a finalizing keydown',
(h: ReturnType<typeof harness>) => h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13 }),
],
])('stops waiting after the same 2 s bound when finalized by %s', (_label, finalize) => {
const h = realTimerHarness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'deleted' });
finalize(h);
h.clear.mockClear();
expect(h.controller.state).toMatchObject({ awaitingCommit: true, timerPending: true });
vi.advanceTimersByTime(1999);
expect(h.controller.state.awaitingCommit).toBe(true);
vi.advanceTimersByTime(1);
expect(h.controller.state).toMatchObject({ awaitingCommit: false, latest: '', timerPending: false });
expect(h.clear).toHaveBeenCalledOnce();
// The next unrelated keystroke or paste is not adopted as the IME commit.
expect(h.controller.consumeTerminalData('x')).toBe(false);
expect(h.controller.consumeTerminalData('pasted line')).toBe(false);
expect(h.onCommit).not.toHaveBeenCalled();
});
test('a commit that arrives in time replaces the wait bound with the committed one', () => {
const h = realTimerHarness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionend', { data: '日本' });
vi.advanceTimersByTime(1500);
expect(h.controller.consumeTerminalData('日本')).toBe(true);
// The wait bound would have fired at 2000 ms; the committed bound runs
// a full 2 s from the commit instead.
vi.advanceTimersByTime(1000);
expect(h.controller.state.committed).toBe(true);
vi.advanceTimersByTime(1000);
expect(h.controller.state.committed).toBe(false);
});
test('a new composition cancels the previous wait bound', () => {
const h = realTimerHarness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionend', { data: '' });
vi.advanceTimersByTime(1500);
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'next' });
vi.advanceTimersByTime(1000);
expect(h.controller.state).toMatchObject({ composing: true, latest: 'next' });
});
});
test('generation fences stale frames and timers', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'old' });
const staleFrame = h.frames[0].callback;
h.textarea.dispatch('compositionstart');
staleFrame();
expect(h.render).not.toHaveBeenCalled();
h.textarea.dispatch('compositionend', { data: 'first' });
h.controller.consumeTerminalData('first');
const staleTimer = h.timers[0].callback;
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'new' });
h.flushFrame();
staleTimer();
expect(h.render).toHaveBeenLastCalledWith({ text: 'new', phase: 'provisional' });
});
test('predicted completion clears immediately while fallback waits for output or TTL', () => {
const predicted = harness();
predicted.textarea.dispatch('compositionstart');
predicted.clear.mockClear();
predicted.textarea.dispatch('compositionend', { data: 'one' });
predicted.controller.consumeTerminalData('one');
expect(predicted.frames).toHaveLength(1);
predicted.controller.completeCommit({ predicted: true });
expect(predicted.clear).toHaveBeenCalledOnce();
expect(predicted.frames).toHaveLength(0);
expect(predicted.timers).toHaveLength(0);
expect(predicted.controller.state.latest).toBe('');
expect(predicted.controller.state.committed).toBe(false);
const fallback = harness();
fallback.textarea.dispatch('compositionstart');
fallback.clear.mockClear();
fallback.textarea.dispatch('compositionend', { data: 'two' });
fallback.controller.consumeTerminalData('two');
fallback.controller.completeCommit({ predicted: false });
expect(fallback.clear).not.toHaveBeenCalled();
fallback.controller.noteAuthoritativeOutput();
expect(fallback.clear).toHaveBeenCalledOnce();
expect(fallback.frames).toHaveLength(0);
expect(fallback.timers).toHaveLength(0);
expect(fallback.controller.state.latest).toBe('');
expect(fallback.controller.state.committed).toBe(false);
const ttl = harness();
ttl.textarea.dispatch('compositionstart');
ttl.clear.mockClear();
ttl.textarea.dispatch('compositionend', { data: 'three' });
ttl.controller.consumeTerminalData('three');
ttl.controller.completeCommit({ predicted: false });
ttl.flushTimer();
expect(ttl.clear).toHaveBeenCalledOnce();
expect(ttl.frames).toHaveLength(0);
expect(ttl.timers).toHaveLength(0);
expect(ttl.controller.state.latest).toBe('');
expect(ttl.controller.state.committed).toBe(false);
});
test('contains re-entrant reset and destroy from commit callbacks without resurrecting work', () => {
let resetController: { reset(): void };
const reset = harness({ onCommit: () => resetController.reset() });
resetController = reset.controller;
reset.textarea.dispatch('compositionstart');
reset.textarea.dispatch('compositionupdate', { data: 'draft' });
reset.textarea.dispatch('compositionend');
expect(reset.controller.consumeTerminalData('final')).toBe(true);
expect(reset.frames).toHaveLength(0);
expect(reset.timers).toHaveLength(0);
expect(reset.controller.state.latest).toBe('');
let destroyController: { destroy(): void };
const destroy = harness({ onCommit: () => destroyController.destroy() });
destroyController = destroy.controller;
destroy.textarea.dispatch('compositionstart');
destroy.textarea.dispatch('compositionend');
expect(destroy.controller.consumeTerminalData('final')).toBe(true);
expect(destroy.textarea.listenerCount()).toBe(0);
expect(destroy.element.listenerCount()).toBe(0);
expect(destroy.frames).toHaveLength(0);
expect(destroy.timers).toHaveLength(0);
expect(destroy.controller.state.latest).toBe('');
});
test('contains re-entrant render and clear callbacks', () => {
let renderController: { reset(): void };
const render = harness({ render: () => renderController.reset() });
renderController = render.controller;
render.textarea.dispatch('compositionstart');
render.textarea.dispatch('compositionupdate', { data: 'draft' });
expect(() => render.flushFrame()).not.toThrow();
expect(render.frames).toHaveLength(0);
expect(render.timers).toHaveLength(0);
expect(render.controller.state.latest).toBe('');
let clearController: { destroy(): void };
const clear = harness({ clear: () => clearController?.destroy() });
clearController = clear.controller;
expect(() => clear.textarea.dispatch('compositionstart')).not.toThrow();
expect(clear.textarea.listenerCount()).toBe(0);
expect(clear.element.listenerCount()).toBe(0);
expect(clear.frames).toHaveLength(0);
expect(clear.timers).toHaveLength(0);
});
test('fails open when frame or timer schedulers throw', () => {
const frame = harness({
scheduleFrame: () => {
throw new Error('frame scheduler');
},
});
frame.textarea.dispatch('compositionstart');
expect(() => frame.textarea.dispatch('compositionupdate', { data: 'safe' })).not.toThrow();
expect(frame.controller.state.framePending).toBe(false);
let lateFrame: (() => void) | undefined;
const timer = harness({
scheduleFrame: (callback: () => void) => {
lateFrame = callback;
return 1;
},
cancelFrame: () => {},
setTimer: () => {
throw new Error('timer scheduler');
},
});
timer.textarea.dispatch('compositionstart');
timer.clear.mockClear();
timer.textarea.dispatch('compositionend');
expect(() => timer.controller.consumeTerminalData('safe')).not.toThrow();
expect(timer.controller.state.committed).toBe(false);
expect(timer.controller.state.latest).toBe('');
expect(timer.controller.state.framePending).toBe(false);
expect(timer.controller.state.timerPending).toBe(false);
expect(timer.clear).toHaveBeenCalledOnce();
lateFrame?.();
expect(timer.render).not.toHaveBeenCalled();
const tokenlessTimer = harness({ setTimer: () => undefined });
tokenlessTimer.textarea.dispatch('compositionstart');
tokenlessTimer.clear.mockClear();
tokenlessTimer.textarea.dispatch('compositionend');
expect(tokenlessTimer.controller.consumeTerminalData('safe')).toBe(true);
expect(tokenlessTimer.controller.state.committed).toBe(false);
expect(tokenlessTimer.controller.state.latest).toBe('');
expect(tokenlessTimer.controller.state.framePending).toBe(false);
expect(tokenlessTimer.controller.state.timerPending).toBe(false);
expect(tokenlessTimer.frames).toHaveLength(0);
expect(tokenlessTimer.timers).toHaveLength(0);
expect(tokenlessTimer.clear).toHaveBeenCalledOnce();
const cancellation = harness({
cancelFrame: () => {
throw new Error('frame cancellation');
},
clearTimer: () => {
throw new Error('timer cancellation');
},
});
cancellation.textarea.dispatch('compositionstart');
cancellation.textarea.dispatch('compositionupdate', { data: 'draft' });
cancellation.textarea.dispatch('compositionend');
cancellation.controller.consumeTerminalData('final');
expect(() => cancellation.controller.reset()).not.toThrow();
expect(cancellation.controller.state.framePending).toBe(false);
expect(cancellation.controller.state.timerPending).toBe(false);
});
test('authoritative output never clears active provisional composition', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.clear.mockClear();
h.textarea.dispatch('compositionupdate', { data: 'active' });
h.flushFrame();
h.controller.noteAuthoritativeOutput();
expect(h.clear).not.toHaveBeenCalled();
});
test('blur and reset cancel scheduled work and clear visual state', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.clear.mockClear();
h.textarea.dispatch('compositionupdate', { data: 'pending' });
h.textarea.dispatch('blur');
expect(h.frames).toHaveLength(0);
expect(h.clear).toHaveBeenCalledOnce();
h.textarea.dispatch('compositionstart');
h.clear.mockClear();
h.textarea.dispatch('compositionupdate', { data: 'again' });
h.controller.reset();
expect(h.frames).toHaveLength(0);
expect(h.clear).toHaveBeenCalledOnce();
expect(h.controller.state.latest).toBe('');
expect(h.controller.state.committed).toBe(false);
});
test('destroy is idempotent and removes listeners and scheduled work', () => {
const h = harness();
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'pending' });
expect(h.textarea.listenerCount()).toBe(5);
expect(h.element.listenerCount()).toBe(1);
h.controller.destroy();
h.controller.destroy();
expect(h.textarea.listenerCount()).toBe(0);
expect(h.element.listenerCount()).toBe(0);
expect(h.frames).toHaveLength(0);
h.textarea.dispatch('compositionupdate', { data: 'ignored' });
expect(h.scheduleFrame).toHaveBeenCalledOnce();
expect(() => h.controller.reset()).not.toThrow();
expect(h.controller.state.latest).toBe('');
expect(h.controller.state.framePending).toBe(false);
expect(h.controller.state.timerPending).toBe(false);
});
test('contains render, clear, and commit callback exceptions', () => {
const h = harness({
render: vi.fn(() => {
throw new Error('render');
}),
clear: vi.fn(() => {
throw new Error('clear');
}),
onCommit: vi.fn(() => {
throw new Error('commit');
}),
});
h.textarea.dispatch('compositionstart');
h.textarea.dispatch('compositionupdate', { data: 'safe' });
expect(() => h.flushFrame()).not.toThrow();
h.textarea.dispatch('compositionend', { data: 'safe' });
expect(() => h.controller.consumeTerminalData('safe')).not.toThrow();
expect(() => h.controller.reset()).not.toThrow();
});
test.each([
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
[{ userAgent: 'Mozilla/5.0 (iPad) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
[{ userAgent: 'Mozilla/5.0 (iPod) AppleWebKit/605.1.15', maxTouchPoints: 1 }, true],
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 2 }, true],
[{ userAgent: 'CriOS/120.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
[{ userAgent: 'Mozilla/5.0 (iPhone) Gecko/120', maxTouchPoints: 5 }, false],
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 0 }, false],
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 1 }, false],
[{ userAgent: 'Mozilla/5.0 (Android) AppleWebKit/537.36', maxTouchPoints: 5 }, false],
])('detects iOS WebKit touch eligibility for %j', (nav, expected) => {
expect(harness().api.isIosWebKitTouch(nav)).toBe(expected);
});
});
@@ -55,6 +55,7 @@ function makeApp() {
getCaseSettings: () => ({}),
buildEnvOverrides: () => ({}),
getEffortSetting: () => undefined,
getAdvisorSetting: () => undefined,
selectSession: vi.fn(async () => {}),
};
}
@@ -104,6 +104,20 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
});
it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => {
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
// fields would drop the effort here without a word.
const out = await _clampExternalCliBypassForOwner(
'peon',
{ dangerouslyBypassApprovals: true, reasoningEffort: 'xhigh' },
undefined,
undefined,
undefined,
undefined
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, reasoningEffort: 'xhigh' });
});
it('leaves codex/antigravity/grok absent when nothing was sent (they already spawn safe)', async () => {
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
expect(out.codexConfig).toBeUndefined();
+261
View File
@@ -0,0 +1,261 @@
/**
* @fileoverview Route tests for /api/mcp-sync. The feature is OPT-IN (`mcpSyncEnabled`, default
* OFF): both verbs answer 403 until it is on. Only CLIs that are ENABLED in the registry take
* part, and only if installed or already configured; enabled agent CLIs with no known MCP
* config are reported as unsupported.
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME, so each test wipes the config files it
* creates. Port: N/A (app.inject()).
*/
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerMcpSyncRoutes } from '../../src/web/routes/mcp-sync-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
// Which CLIs are installed on the machine running the tests must not decide the outcome: nothing
// is installed, so only a CLI whose config file exists takes part.
// Lets a test hold the module's real apply lock open: the first apply parks inside the mock
// (after taking the lock) until released, so a second POST deterministically overlaps it.
const hold = vi.hoisted(() => ({ release: null as null | (() => void), entered: null as null | (() => void) }));
vi.mock('../../src/mcp-sync.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/mcp-sync.js')>();
return {
...actual,
syncMcpServers: async (...args: Parameters<typeof actual.syncMcpServers>) => {
if (!hold.release || !args[1].apply) return actual.syncMcpServers(...args);
const parked = new Promise<void>((resolve) => (hold.release = resolve));
hold.entered?.();
const pending = actual.syncMcpServers(...args); // takes the lock synchronously
await parked;
return pending;
},
};
});
// Nothing is installed unless a test adds the id here.
const installed = vi.hoisted(() => new Set<string>());
vi.mock('../../src/utils/cli-installed-probes.js', () => ({
probeStockCliAvailability: async () => ({}),
isCliEntryInstalled: (e: { id: string }) => installed.has(e.id),
}));
// The route follows each CLI's relocation env var (CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME,
// ...) from process.env, so the runner's own values (CI images set XDG_CONFIG_HOME) must never
// aim a test write outside the temp HOME. Cleared before every test, restored after the file.
const RELOCATION_VARS = STOCK_CLIS.flatMap((e) => {
const envVar = e.capabilities.mcpConfig?.relocation?.envVar;
return envVar ? [envVar] : [];
});
const savedEnv = Object.fromEntries(RELOCATION_VARS.map((k) => [k, process.env[k]]));
afterAll(() => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
const home = () => homedir();
const write = (rel: string, text: string) => {
const f = join(home(), rel);
mkdirSync(dirname(f), { recursive: true });
writeFileSync(f, text);
};
const setEnabled = (on: boolean | undefined) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { mcpSyncEnabled: on }));
};
const disable = (...ids: string[]) => {
const file = registryFilePath();
mkdirSync(dirname(file), { recursive: true });
const clis = Object.fromEntries(ids.map((id) => [id, { enabled: false }]));
writeFileSync(file, JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
reloadCliRegistry();
};
const CLAUDE = '.claude.json';
const CODEX = '.codex/config.toml';
const GEMINI = '.gemini/settings.json';
beforeEach(() => {
for (const k of RELOCATION_VARS) delete process.env[k];
installed.clear();
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
for (const d of ['.claude.json', '.codex', '.gemini', '.config', 'relocated'])
rmSync(join(home(), d), { recursive: true, force: true });
write(CLAUDE, JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', args: ['-y', 'fs'] } } }));
// Codex and Gemini have been set up on this machine (their config files exist).
write(CODEX, 'model = "gpt-5"\n');
write(GEMINI, '{}');
setEnabled(true);
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(registryFilePath(), { force: true });
rmSync(SETTINGS_PATH, { force: true });
reloadCliRegistry();
});
describe('/api/mcp-sync — opt-in', () => {
it.each([
['absent', undefined],
['false', false],
])('answers 403 on both verbs and writes nothing while the setting is %s', async (_label, value) => {
setEnabled(value);
const before = readFileSync(join(home(), CODEX), 'utf8');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
for (const method of ['GET', 'POST'] as const) {
const res = await app.inject({ method, url: '/api/mcp-sync' });
expect(res.statusCode, method).toBe(403);
expect(res.json().error).toMatch(/disabled/i);
}
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('applies the toggle on the next request, with no restart', async () => {
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
setEnabled(false);
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(403);
setEnabled(true);
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).statusCode).toBe(200);
});
});
describe('/api/mcp-sync', () => {
it('GET previews without writing', async () => {
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const before = readFileSync(join(home(), CODEX), 'utf8');
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.applied).toBe(false);
expect(body.data.targets.find((t: { id: string }) => t.id === 'codex').added).toEqual(['fs']);
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('POST adds the server to every enabled, set-up CLI', async () => {
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
expect(res.json().data.applied).toBe(true);
expect(readFileSync(join(home(), CODEX), 'utf8')).toContain('[mcp_servers.fs]');
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
});
it('never creates config for an enabled CLI that is not installed and has no config file', async () => {
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
const opencode = res.json().data.targets.find((t: { id: string }) => t.id === 'opencode');
// Nothing is installed (mocked) and opencode has no config under the temp HOME.
expect(opencode.status).toBe('absent');
expect(existsSync(join(home(), '.config'))).toBe(false);
});
it('never touches a CLI that is disabled in the registry', async () => {
disable('codex');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const before = readFileSync(join(home(), CODEX), 'utf8');
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
const ids = res.json().data.targets.map((t: { id: string }) => t.id);
expect(ids).not.toContain('codex');
expect(ids).toContain('gemini');
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
expect(JSON.parse(readFileSync(join(home(), GEMINI), 'utf8')).mcpServers.fs.command).toBe('npx');
});
it('lists installed, enabled agent CLIs without MCP support, and omits disabled, uninstalled ones and the shell', async () => {
installed.add('grok').add('pi');
disable('pi');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const { unsupported } = (await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().data;
expect(unsupported).toEqual(['Grok']);
});
it('follows CODEX_HOME from the server env instead of writing the default ~/.codex', async () => {
const codexHome = join(home(), 'relocated/codex');
process.env.CODEX_HOME = codexHome;
write('relocated/codex/config.toml', 'model = "gpt-5"\n');
const before = readFileSync(join(home(), CODEX), 'utf8');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
expect(codex.file).toBe(join(codexHome, 'config.toml'));
expect(codex.added).toEqual(['fs']);
expect(readFileSync(join(codexHome, 'config.toml'), 'utf8')).toContain('[mcp_servers.fs]');
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('reports a relative CODEX_HOME as skipped and writes no codex file', async () => {
process.env.CODEX_HOME = 'relative/codex';
installed.add('codex');
const before = readFileSync(join(home(), CODEX), 'utf8');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
expect(codex.status).toBe('skipped');
expect(codex.error).toMatch(/CODEX_HOME/);
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('never echoes the text of a config file it cannot parse', async () => {
write(CODEX, 'model = "gpt-5"\n[mcp_servers.linear]\nenv = { LINEAR_API_KEY = "lin_SECRET_abc" broken }\n');
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'GET', url: '/api/mcp-sync' });
const codex = res.json().data.targets.find((t: { id: string }) => t.id === 'codex');
expect(codex.status).toBe('unreadable');
expect(codex.error).toMatch(/^not valid TOML \(line 3, column \d+\)$/);
expect(res.body).not.toContain('lin_SECRET_abc');
});
it('never returns env values or headers', async () => {
write(
CLAUDE,
JSON.stringify({ mcpServers: { fs: { type: 'stdio', command: 'npx', env: { TOKEN: 'sekrit-value' } } } })
);
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
const res = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
expect(res.body).not.toContain('sekrit-value');
});
it('answers 409 to an apply that overlaps another, and a later apply succeeds', async () => {
const { app } = await createRouteTestHarness(registerMcpSyncRoutes);
hold.release = () => undefined;
const entered = new Promise<void>((resolve) => (hold.entered = resolve));
const first = app.inject({ method: 'POST', url: '/api/mcp-sync' });
await entered; // the first apply now holds the lock
const release = hold.release;
hold.release = null; // the overlapping request goes straight to the real function
const second = await app.inject({ method: 'POST', url: '/api/mcp-sync' });
expect(second.statusCode).toBe(409);
expect(second.json().errorCode).toBe('CONFLICT');
release?.();
expect((await first).statusCode).toBe(200);
expect((await app.inject({ method: 'POST', url: '/api/mcp-sync' })).statusCode).toBe(200);
});
it('multi-user: a non-admin is refused on both verbs and nothing is written', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const before = readFileSync(join(home(), CODEX), 'utf8');
for (const method of ['GET', 'POST'] as const) {
const res = await app.inject({ method, url: '/api/mcp-sync' });
expect(res.statusCode, method).toBe(403);
}
expect(readFileSync(join(home(), CODEX), 'utf8')).toBe(before);
});
it('multi-user: an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerMcpSyncRoutes, {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/mcp-sync' })).json().success).toBe(true);
});
});
@@ -0,0 +1,162 @@
/**
* @fileoverview `model` on POST /api/sessions — a Claude model for one session only.
*
* Claude's model reaches disk only through `modelOverride`, which writes it into the
* case's `.claude/settings.local.json` for every later run there. `model` is the
* per-session counterpart: it goes out as `claude --model <id>`, wins over the app-wide
* default, and writes nothing. What the tests read is the model the session hands the
* mux when it starts, which is what becomes the `--model` flag.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions model', () => {
let workingDir: string;
let harness: Harness;
beforeEach(async () => {
workingDir = await mkdtemp(join(tmpdir(), 'codeman-session-model-'));
harness = await createHarness();
});
afterEach(async () => {
await harness.app.close();
await rm(workingDir, { recursive: true, force: true });
});
/** Creates a session and starts it, then returns the model it handed the mux. */
async function launchedModel(payload: Record<string, unknown>): Promise<unknown> {
const res = await harness.app.inject({ method: 'POST', url: '/api/sessions', payload: { workingDir, ...payload } });
expect(res.statusCode).toBe(200);
const parsed = JSON.parse(res.body);
const id = (parsed.data?.session ?? parsed.session).id as string;
await harness.app.inject({ method: 'POST', url: `/api/sessions/${id}/interactive`, payload: {} });
const calls = harness.ctx.mux.createSession.mock.calls;
expect(calls.length).toBeGreaterThan(0);
return (calls[calls.length - 1][0] as { model?: string }).model;
}
it('launches a Claude session on the model the caller names', async () => {
expect(await launchedModel({ mode: 'claude', model: 'claude-fable-5-1' })).toBe('claude-fable-5-1');
});
it('wins over the app-wide default model', async () => {
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
expect(await launchedModel({ mode: 'claude', model: 'opus' })).toBe('opus');
});
it('leaves the app-wide default in charge when the caller names none', async () => {
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
expect(await launchedModel({ mode: 'claude' })).toBe('sonnet');
});
it('launches on `model` while `modelOverride` alone reaches the case file', async () => {
// Sent together, each lands where it belongs: the persistent default in the case's
// settings.local.json, and this session's model on its launch line. A route that wrote
// `model` to disk would put 'opus' in the file; one that ignored it would launch 'sonnet'.
expect(await launchedModel({ mode: 'claude', model: 'opus', modelOverride: 'sonnet' })).toBe('opus');
const settings = JSON.parse(await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8'));
expect(settings.model).toBe('sonnet');
});
it('reads an empty model as no model, as modelOverride does', async () => {
harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' });
expect(await launchedModel({ mode: 'claude', model: '' })).toBe('sonnet');
});
it('refuses a model for a CLI that takes its model in its own config object', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir, mode: 'codex', model: 'gpt-5' },
});
const parsed = JSON.parse(res.body);
expect(parsed.success).toBe(false);
expect(parsed.errorCode).toBe('INVALID_INPUT');
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
});
it('rejects a model with characters the launch pattern refuses', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir, mode: 'claude', model: 'opus; rm -rf ~' },
});
expect(res.statusCode).toBe(400);
});
it('rejects a flag-shaped model, since the value lands in argv', async () => {
for (const model of ['--dangerously-skip-permissions', '-p', '.hidden', '[1m]']) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir, mode: 'claude', model },
});
expect(res.statusCode, model).toBe(400);
}
expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with
});
it('still accepts real model ids, aliases and the [1m] suffix', async () => {
for (const model of ['claude-fable-5-1', 'opus', 'opus[1m]', 'claude-opus-5-5[1m]']) {
expect(await launchedModel({ mode: 'claude', model })).toBe(model);
}
});
it.each([
['model', { model: 'opus' }],
['advisorModel', { advisorModel: 'opus' }],
])('refuses %s on a remote attach, which launches nothing', async (_field, extra) => {
// Refused before the host is looked up, so no remote host needs to exist.
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: {
mode: 'claude',
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
...extra,
},
});
const parsed = JSON.parse(res.body);
expect(parsed.success).toBe(false);
expect(parsed.errorCode).toBe('INVALID_INPUT');
expect(harness.ctx.sessions.size).toBe(1);
});
it('publishes the launch model on the created claude session', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir, mode: 'claude', model: 'claude-fable-5-1', advisorModel: 'opus' },
});
const parsed = JSON.parse(res.body);
const session = parsed.data?.session ?? parsed.session;
expect(session.model).toBe('claude-fable-5-1');
expect(session.advisorModel).toBe('opus');
});
});
+71 -1
View File
@@ -17,7 +17,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
@@ -165,6 +167,74 @@ describe('session-routes', () => {
expect(argv).toContain('-H');
});
describe('newline chord comes from the CLI registry (capabilities.newline)', () => {
const sentHex = async (mode: string, key: string): Promise<string[]> => {
execFile.mockReset();
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
cb(null)
);
const session = harness.ctx._session as unknown as { mode: string };
const before = session.mode;
session.mode = mode;
try {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key },
});
expect(res.statusCode).toBe(200);
} finally {
session.mode = before;
}
const argv = execFile.mock.calls[0][1] as string[];
return argv.slice(argv.indexOf('-H') + 3); // after "-H -t <pane>"
};
it('sends a line feed for Shift+Enter to a CLI that declares nothing', async () => {
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
expect(await sentHex('opencode', 'S-Enter')).toEqual(['0a']);
});
it('sends a line feed to Codex too: no stock CLI declares a chord', async () => {
expect(await sentHex('codex', 'S-Enter')).toEqual(['0a']);
});
describe('a CLI that declares esc-enter (here via a user clis.json override of codex)', () => {
beforeEach(() => {
const file = registryFilePath();
mkdirSync(dirname(file), { recursive: true });
writeFileSync(
file,
JSON.stringify({ schemaVersion: 1, clis: { codex: { capabilities: { newline: 'esc-enter' } } } }),
{ mode: 0o600 }
);
reloadCliRegistry();
});
afterEach(() => {
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
});
it('sends Esc+Enter for Shift+Enter, and only to that CLI', async () => {
expect(await sentHex('codex', 'S-Enter')).toEqual(['1b', '0d']);
expect(await sentHex('claude', 'S-Enter')).toEqual(['0a']);
});
it('still sends a line feed for Ctrl+Enter', async () => {
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
});
});
it('always sends a line feed for Ctrl+Enter', async () => {
expect(await sentHex('codex', 'C-Enter')).toEqual(['0a']);
expect(await sentHex('claude', 'C-Enter')).toEqual(['0a']);
});
it('falls back to a line feed for a mode the registry does not know', async () => {
expect(await sentHex('no-such-cli', 'S-Enter')).toEqual(['0a']);
});
});
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
execFile.mockReset();
const res = await harness.app.inject({
+170
View File
@@ -0,0 +1,170 @@
/**
* @fileoverview /api/webhook: the webhook-notification config. The URL is a bearer secret, so it
* is never returned and the routes are admin only in multi-user mode.
* Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerWebhookRoutes } from '../../src/web/routes/webhook-routes.js';
import { readWebhookConfig, webhookConfigPath, WebhookNotifier, type WebhookFetch } from '../../src/webhook-notify.js';
const SECRET_URL = 'https://hooks.slack.com/services/T0/B0/SUPERSECRET';
let dir: string;
let fetchImpl: ReturnType<typeof vi.fn<WebhookFetch>>;
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
const notifier = new WebhookNotifier(() => readWebhookConfig(dir), fetchImpl);
const h = await createRouteTestHarness(
(app) => registerWebhookRoutes(app, { notifier, configDir: dir, hostTitle: () => 'codeman:test' }),
authUser ? { authUser } : undefined
);
return { ...h, notifier };
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'webhook-routes-'));
fetchImpl = vi.fn<WebhookFetch>(async () => new Response('', { status: 200 }));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
describe('GET /api/webhook', () => {
it('starts disabled with no URL', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: '/api/webhook' });
expect(res.json().data).toEqual({
enabled: false,
kind: 'ntfy',
scope: 'attention',
hasUrl: false,
urlMasked: '',
lastResult: null,
});
});
});
describe('PUT /api/webhook', () => {
it('saves the config, masks the URL in every response, and writes the file 0600', async () => {
const { app } = await harness();
const put = await app.inject({
method: 'PUT',
url: '/api/webhook',
payload: { enabled: true, kind: 'slack', scope: 'all', url: SECRET_URL },
});
expect(put.statusCode).toBe(200);
expect(put.json().data).toMatchObject({ enabled: true, kind: 'slack', scope: 'all', hasUrl: true });
expect(put.json().data.urlMasked).toBe('https://hooks.slack.com/•••');
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
for (const body of [put.body, get.body]) expect(body).not.toMatch(/SUPERSECRET|T0\/B0/);
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
expect(statSync(webhookConfigPath(dir)).mode & 0o777).toBe(0o600);
});
it('changing kind or scope keeps the saved URL (the secret is never re-sent)', async () => {
const { app } = await harness();
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'discord' } });
expect(await readWebhookConfig(dir)).toMatchObject({ kind: 'discord', url: SECRET_URL, enabled: true });
});
it('an empty url clears it', async () => {
const { app } = await harness();
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
expect(res.json().data).toMatchObject({ hasUrl: false, urlMasked: '' });
expect((await readWebhookConfig(dir)).url).toBe('');
});
it.each([
['enabling with no URL', { enabled: true }, /Add a webhook URL/],
['a metadata address', { url: 'http://169.254.169.254/latest' }, /metadata|link-local/],
['a non-http scheme', { url: 'file:///etc/passwd' }, /http and https/],
['credentials in the URL', { url: 'https://u:p@example.com/x' }, /credentials/],
['clearing the URL while enabled', null, /Add a webhook URL/],
])('rejects %s with 400 and saves nothing', async (_label, payload, why) => {
const { app } = await harness();
if (payload === null) {
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { enabled: true, url: SECRET_URL } });
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: '' } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(why);
expect((await readWebhookConfig(dir)).url).toBe(SECRET_URL);
return;
}
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(why);
expect(await readWebhookConfig(dir)).toMatchObject({ enabled: false, url: '' });
});
it('rejects unknown keys and bad enums (strict schema)', async () => {
const { app } = await harness();
for (const payload of [{ extra: 1 }, { kind: 'telegram' }, { scope: 'everything' }, { enabled: 'yes' }]) {
const res = await app.inject({ method: 'PUT', url: '/api/webhook', payload });
expect(res.statusCode, JSON.stringify(payload)).toBe(400);
}
});
});
describe('POST /api/webhook/test', () => {
it('refuses with 400 until a URL is saved', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
expect(res.statusCode).toBe(400);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('sends one message with the saved config, even while notifications are disabled', async () => {
const { app, notifier } = await harness();
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { kind: 'generic', url: SECRET_URL } });
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ ok: true, status: 200 });
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetchImpl.mock.calls[0][1].body as string)).toMatchObject({
host: 'codeman:test',
event: 'webhook:test',
});
expect(notifier.lastResult?.ok).toBe(true);
expect(res.body).not.toContain('SUPERSECRET');
});
it('reports a delivery failure in data (HTTP 200) without leaking the URL, and GET shows it as the last result', async () => {
fetchImpl.mockImplementation(async () => new Response('', { status: 404 }));
const { app } = await harness();
await app.inject({ method: 'PUT', url: '/api/webhook', payload: { url: SECRET_URL } });
const res = await app.inject({ method: 'POST', url: '/api/webhook/test' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' });
const get = await app.inject({ method: 'GET', url: '/api/webhook' });
expect(get.json().data.lastResult).toMatchObject({ ok: false, status: 404 });
expect(get.body).not.toContain('SUPERSECRET');
});
});
describe('multi-user', () => {
it.each([
['GET', '/api/webhook'],
['PUT', '/api/webhook'],
['POST', '/api/webhook/test'],
] as const)('refuses a non-admin on %s %s and touches nothing', async (method, url) => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ username: 'bob', role: 'user' });
const res = await app.inject({ method, url, payload: method === 'PUT' ? { url: SECRET_URL } : undefined });
expect(res.statusCode).toBe(403);
expect((await readWebhookConfig(dir)).url).toBe('');
expect(fetchImpl).not.toHaveBeenCalled();
});
it('allows an admin', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ username: 'root', role: 'admin' });
expect((await app.inject({ method: 'GET', url: '/api/webhook' })).statusCode).toBe(200);
});
});
-85
View File
@@ -1,85 +0,0 @@
/**
* @fileoverview Static guard for the Run menus' per-CLI logos (styles.css).
*
* Every launch surface (toolbar Run menu, phone overview picker, Custom Endpoint
* rows, model picker) renders `<span class="run-mode-dot <id>">`, and styles.css
* turns that slot into the CLI's logo through a `--run-mode-logo` data URI plus
* one of two paint groups: brand-coloured marks paint as a background image,
* monochrome marks as a mask over the row's text colour. Three ways that breaks
* silently, each pinned here:
* - a new stock CLI lands with no logo rule (its row shows a bare grey dot);
* - a logo rule exists but the id is in neither paint group (the variable is
* set and never painted, so the slot is a blank 15px gap);
* - a later `background:` shorthand on `.run-mode-dot.<id>` (e.g. a skin
* override like the ones the non-og block used to carry) resets
* background-image and wipes the logo.
*
* Port: none (pure static analysis).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
// Comments stripped: they sit between rules and carry commas, which would bleed
// into the selector lists split below.
const styles = readFileSync(resolve('src/web/public/styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
/** Every id a Run menu renders as `.run-mode-dot <id>`: the stock agents, Shell, and web tabs. */
const RUN_MENU_IDS = [
...STOCK_CLIS.filter((cli) => cli.kind === 'agent').map((cli) => cli.id as string),
'shell',
'web',
];
/** The selector list of the one rule whose body contains `marker`. */
function groupIds(marker: string): string[] {
const rule = [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => m[2].includes(marker));
expect(rule, `exactly one rule paints via "${marker}"`).toHaveLength(1);
return rule[0][1]
.split(',')
.map((s) => s.trim().match(/^\.run-mode-dot\.([a-z0-9-]+)$/)?.[1])
.filter((id): id is string => Boolean(id));
}
function logoSvg(id: string): string {
const m = styles.match(
new RegExp(`\\.run-mode-dot\\.${id} \\{ --run-mode-logo: url\\("data:image/svg\\+xml,([^"]+)"\\); \\}`)
);
expect(m, `.run-mode-dot.${id} has a --run-mode-logo rule`).not.toBeNull();
return decodeURIComponent(m![1]);
}
describe('Run menu CLI logos', () => {
const colourIds = groupIds('background: var(--run-mode-logo)');
const maskIds = groupIds('mask: var(--run-mode-logo)');
it.each(RUN_MENU_IDS)('%s has a logo that one paint group draws', (id) => {
const svg = logoSvg(id);
expect(svg.startsWith('<svg')).toBe(true);
expect(svg).toMatch(/viewBox='[\d. ]+'/);
expect(colourIds.includes(id) !== maskIds.includes(id), `${id} is in exactly one paint group`).toBe(true);
});
it('defines no logo that no surface renders, and no group member without a logo', () => {
expect([...colourIds, ...maskIds].sort()).toEqual([...RUN_MENU_IDS].sort());
});
it('carries no script or event handler inside a data URI', () => {
for (const id of RUN_MENU_IDS) {
const svg = logoSvg(id);
expect(svg).not.toMatch(/<script|\son[a-z]+=|javascript:|href=/i);
}
});
it('never resets a logo slot with a background shorthand outside the colour group', () => {
const offenders = [...styles.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter(
([, selector, body]) =>
/\.run-mode-dot\.[a-z]/.test(selector) &&
/(^|[;\s])background\s*:/.test(body) &&
!body.includes('var(--run-mode-logo)')
);
expect(offenders.map((m) => m[1].trim())).toEqual([]);
});
});
+222
View File
@@ -0,0 +1,222 @@
/**
* @fileoverview A session's launch model survives recovery.
*
* `Session._model` is what becomes `claude --model <id>`: the caller's per-session `model`
* from POST /api/sessions, or the app-wide default. It lives in memory, so it reaches a
* relaunch after a Codeman restart or a reboot restore only if `toState()` persists it and
* both recovery constructors hand it back. Without that, a recovered session silently
* relaunches on the account default.
*
* `restoreMuxSessions()` (server.ts) cannot be reached under vitest, where
* `reconcileSessions()` reports every pane alive, and the reboot-restore route rejects every
* workspace before building a Session in its route tests. The two constructors are therefore
* pinned by a source check, the same way `test/remote-wake.test.ts` pins its wiring, and the
* round trip itself is driven through a real `Session` against the in-memory tmux layer.
*/
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import { Session } from '../src/session.js';
import { TmuxManager, buildSpawnCommand } from '../src/tmux-manager.js';
import type { MuxSession, RespawnPaneOptions, TerminalMultiplexer } from '../src/mux-interface.js';
import type { EffortLevel } from '../src/types.js';
const SRC = fileURLToPath(new URL('../src', import.meta.url));
describe('the launch model survives recovery', () => {
const workingDir = join(homedir(), 'codeman-cases', 'session-model-recovery');
const sessions: Session[] = [];
afterEach(() => {
for (const s of sessions.splice(0)) s.stop();
rmSync(workingDir, { recursive: true, force: true });
});
it('persists the model in the session state', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude', model: 'claude-fable-5-1' });
sessions.push(session);
expect(session.toState().model).toBe('claude-fable-5-1');
});
it('relaunches a session rebuilt from that state on the same model', async () => {
mkdirSync(workingDir, { recursive: true });
const original = new Session({ workingDir, mode: 'claude', model: 'claude-fable-5-1' });
sessions.push(original);
const state = original.toState();
// Rebuilt the way both recovery paths build one, from the persisted record.
const mux = new TmuxManager();
const createSession = vi.spyOn(mux, 'createSession');
const rebuilt = new Session({
id: state.id,
workingDir,
mode: state.mode,
mux,
useMux: true,
model: state.model,
});
sessions.push(rebuilt);
await rebuilt.startInteractive();
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-fable-5-1' }));
});
it('is handed back by both recovery constructors', () => {
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
const reboot = readFileSync(join(SRC, 'web', 'routes', 'reboot-restore-routes.ts'), 'utf-8');
expect(server).toMatch(/model:\s*savedState\?\.model,/);
expect(reboot).toMatch(/model:\s*saved\.model,/);
});
it('is neither published nor persisted for a CLI that keeps its model in its own config', () => {
// Cron hands the app-wide default (a Claude id) to every CLI that has a model at all.
// codex never launches on the top-level field, so publishing it would report a model the
// session never ran on, and recovery would carry that wrong value forward.
for (const mode of ['codex', 'opencode', 'shell'] as const) {
const session = new Session({ workingDir: '/tmp', mode, model: 'claude-fable-5-1' });
sessions.push(session);
expect(session.toState().model, mode).toBeUndefined();
}
});
});
/**
* #514 (per-session `model` → `--model`) and #530 (`advisorModel` → the launch's ONE
* `--settings` JSON) landed together and touch the same launch and recovery code. A claude
* session carrying both, with or without ultracode (whose blob shares that `--settings`
* object), must relaunch with both on every recovery path.
*/
describe('a launch model and an advisor survive recovery together', () => {
const MODEL = 'claude-fable-5-1';
const ADVISOR = 'opus';
const sessions: Session[] = [];
afterEach(async () => {
for (const s of sessions.splice(0)) await s.stop();
});
/** The settings JSON exactly as a shell would hand it to claude. */
function settingsOf(cmd: string): unknown {
expect(cmd.match(/--settings /g)).toHaveLength(1);
const tail = cmd.slice(cmd.indexOf('--settings '));
return JSON.parse(execFileSync('bash', ['-c', `set -- ${tail}; printf '%s' "$2"`]).toString());
}
/** Render what tmux-manager hands buildSpawnCommand for these options. */
function launchLine(options: Pick<RespawnPaneOptions, 'sessionId' | 'model' | 'effort' | 'advisorModel'>): string {
return buildSpawnCommand({
mode: 'claude',
sessionId: options.sessionId,
model: options.model,
effort: options.effort,
advisorModel: options.advisorModel,
claudeCliVersion: null,
});
}
function expectBoth(cmd: string, effort: EffortLevel | undefined): void {
expect(cmd).toContain(`--model "${MODEL}"`);
expect(cmd).not.toContain('--advisor ');
expect(settingsOf(cmd)).toEqual(
effort === 'ultracode' ? { ultracode: true, advisorModel: ADVISOR } : { advisorModel: ADVISOR }
);
if (effort && effort !== 'ultracode') expect(cmd).toContain(`--effort '${effort}'`);
}
function persistedRecord(effort: EffortLevel | undefined) {
const original = new Session({ workingDir: '/tmp', mode: 'claude', model: MODEL, advisorModel: ADVISOR, effort });
sessions.push(original);
const state = original.toState();
expect(state.model).toBe(MODEL);
expect(state.advisorModel).toBe(ADVISOR);
return state;
}
it.each([['ultracode'], ['high'], [undefined]] as const)(
'reboot restore (fresh pane) relaunches on both, effort %s',
async (effort) => {
const state = persistedRecord(effort);
// The reboot-restore constructor: no muxSession, so startInteractive() creates a pane.
const mux = new TmuxManager();
const createSession = vi.spyOn(mux, 'createSession');
const rebuilt = new Session({
id: state.id,
workingDir: '/tmp',
mode: state.mode,
mux,
useMux: true,
effort: state.effort,
model: state.model,
advisorModel: state.advisorModel,
});
sessions.push(rebuilt);
await rebuilt.startInteractive();
expect(createSession).toHaveBeenCalledTimes(1);
const options = createSession.mock.calls[0][0];
expect(options).toEqual(expect.objectContaining({ model: MODEL, advisorModel: ADVISOR, effort }));
expectBoth(launchLine(options), effort);
}
);
it.each([['ultracode'], ['high'], [undefined]] as const)(
'restoreMuxSessions onto a dead pane respawns on both, effort %s',
async (effort) => {
const state = persistedRecord(effort);
const respawns: RespawnPaneOptions[] = [];
const mux = {
isAvailable: () => true,
muxSessionExists: () => true,
isPaneDead: () => true,
setAttached: () => {},
respawnPane: async (options: RespawnPaneOptions) => {
respawns.push(options);
return 4242;
},
} as unknown as TerminalMultiplexer;
// The restoreMuxSessions() constructor: an existing muxSession, whose pane is dead, so
// startInteractive() takes the dead-pane respawn.
const rebuilt = new Session({
id: state.id,
workingDir: '/tmp',
mode: state.mode,
mux,
useMux: true,
muxSession: { muxName: 'codeman-aaaa', sessionId: state.id } as unknown as MuxSession,
effort: state.effort,
model: state.model,
advisorModel: state.advisorModel,
});
sessions.push(rebuilt);
await rebuilt.startInteractive();
expect(respawns).toHaveLength(1);
expect(respawns[0]).toEqual(expect.objectContaining({ model: MODEL, advisorModel: ADVISOR, effort }));
expectBoth(launchLine(respawns[0]), effort);
}
);
it('both recovery constructors hand back model, advisorModel and effort', () => {
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
const reboot = readFileSync(join(SRC, 'web', 'routes', 'reboot-restore-routes.ts'), 'utf-8');
for (const field of ['effort', 'model', 'advisorModel']) {
expect(server).toMatch(new RegExp(`\\b${field}:\\s*savedState\\?\\.${field},`));
expect(reboot).toMatch(new RegExp(`\\b${field}:\\s*saved\\.${field},`));
}
});
it('tmux-manager forwards model, effort and advisorModel to both launch builders', () => {
// createSession() and respawnPane() each build the pane command. The tests above stop at
// the options a Session hands them, so this pins the last hop to the builder.
const tmux = readFileSync(join(SRC, 'tmux-manager.ts'), 'utf-8');
const calls = [...tmux.matchAll(/buildSpawnCommand\(\{([^}]*)\}\)/g)].map((m) => m[1]);
expect(calls).toHaveLength(2);
for (const call of calls) {
for (const field of ['model', 'effort', 'advisorModel']) expect(call).toMatch(new RegExp(`\\b${field},`));
}
});
});
@@ -0,0 +1,21 @@
// @vitest-environment node
// Regression guard: xterm runs the custom key handler for keydown AND keypress.
// It discards a keypress carrying Ctrl/Alt but not one carrying only Shift, so
// a handler that returns false for keydown alone lets Shift+Enter's keypress
// through as a bare \r (submit). The Enter gate must therefore not be keyed on
// ev.type === 'keydown'; only the send-key fetch is.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
describe.each(['terminal-ui.js', 'terminal-split.js'])('%s Shift/Ctrl+Enter handler', (file) => {
const src = readFileSync(join(PUBLIC, file), 'utf8');
it('swallows every event type for Shift/Ctrl+Enter', () => {
expect(src).toMatch(/ev\.key === 'Enter' && \(ev\.shiftKey \|\| ev\.ctrlKey\)\) \{/);
expect(src).not.toMatch(/ev\.key === 'Enter' && \(ev\.shiftKey \|\| ev\.ctrlKey\) && ev\.type === 'keydown'/);
});
});
+217
View File
@@ -0,0 +1,217 @@
/**
* @fileoverview Shift/Ctrl+Enter against the SHIPPED key handlers, with real keystrokes in Chromium.
*
* xterm runs the custom key handler for keydown, keypress AND keyup, and drops a keypress that
* carries Ctrl/Alt but NOT a Shift-only one. A handler that returns false for keydown alone
* therefore lets Shift+Enter's keypress fall through to onData as a bare \r, which went out over
* the WebSocket ahead of the async send-key fetch and submitted the prompt (#520). Ctrl+Enter
* worked all along because Chromium fires no keypress for it.
*
* The page is the real app served by a real WebServer, so the handlers under test are the ones
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
* no session exists behind the ids, so nothing is ever typed into a real pane.
*
* Browser-driven, so it is excluded from `npm run test:ci` like the other Playwright suites:
* npm run test:browser -- test/shift-enter-keypress.browser.test.ts
* The CI gate's guard on the same handlers is the source check in
* test/shift-enter-keypress-swallowed.test.ts.
*
* Port: 3267 (per CLAUDE.md, ports 3150+ for tests)
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3267;
const BASE_URL = `http://localhost:${PORT}`;
const MAIN_ID = 'shift-enter-probe-main';
const PANE_B_ID = 'shift-enter-probe-pane-b';
interface Pressed {
/** What xterm emitted through onData, i.e. what would have been written to the PTY. */
data: string[];
/** The send-key POSTs the handler made, as `<session id> <key>`. */
sendKeys: string[];
/** Whether xterm's own textarea held focus, so a key that emits nothing was really delivered. */
focused: boolean;
}
describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be swallowed too)', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.evaluate(
({ paneBId }) => {
const w = window as any;
// Record send-key instead of reaching the server (no tmux pane exists behind these ids).
w.__sendKeys = [] as string[];
const realFetch = window.fetch.bind(window);
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const m = /\/api\/sessions\/([^/]+)\/send-key$/.exec(url);
if (m) {
w.__sendKeys.push(`${m[1]} ${JSON.parse(String(init?.body ?? '{}')).key}`);
return Promise.resolve(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
);
}
return realFetch(input, init);
};
// Pane B, built by the real class. connect() installs the key handler synchronously,
// before its first await; the buffer fetch and WebSocket for the missing session just fail.
const mount = document.createElement('div');
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
document.body.appendChild(mount);
const pane = new w.SplitTerminalPane(paneBId, mount, { mode: 'claude' });
void pane.connect().catch(() => {});
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
w.__paneBData = [] as string[];
pane.terminal.onData((d: string) => w.__paneBData.push(d));
w.__paneB = pane;
// The bare xterm the old keydown-only gate is reproduced on (see the first test).
const host = document.createElement('div');
host.style.cssText = 'position:fixed;left:420px;top:0;width:400px;height:300px;';
document.body.appendChild(host);
const old = new w.Terminal();
old.open(host);
w.__oldData = [] as string[];
old.onData((d: string) => w.__oldData.push(d));
old.attachCustomKeyEventHandler(
(ev: KeyboardEvent) => !(ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown')
);
w.__oldTerm = old;
},
{ paneBId: PANE_B_ID }
);
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/**
* Press `key` in the main pane. `sessionId` null is the welcome screen; otherwise the app thinks
* that session is active, local echo is off (the desktop default) and its real send path,
* `_sendInputAsync`, is recorded instead of posting.
*/
async function pressMain(key: string, sessionId: string | null): Promise<Pressed> {
await page.evaluate((id) => {
const w = window as any;
const app = w.app;
w.__mainSaved ??= { id: app.activeSessionId, echo: app._localEchoEnabled, send: app._sendInputAsync };
w.__mainData = [] as string[];
w.__sendKeys.length = 0;
app.activeSessionId = id;
app._localEchoEnabled = false;
app._pendingInput = '';
app._sendInputAsync = (_sid: string, chunk: string) => void w.__mainData.push(chunk);
app.terminal.focus();
}, sessionId);
const focused = await page.evaluate(() => document.activeElement === (window as any).app.terminal.textarea);
await page.keyboard.press(key);
// The main pane batches onData through a short flush timer before _sendInputAsync.
await page.waitForTimeout(150);
return page.evaluate((f) => {
const w = window as any;
const app = w.app;
const out = { data: [...w.__mainData], sendKeys: [...w.__sendKeys], focused: f };
app.activeSessionId = w.__mainSaved.id;
app._localEchoEnabled = w.__mainSaved.echo;
app._sendInputAsync = w.__mainSaved.send;
app._pendingInput = '';
return out;
}, focused);
}
async function pressIn(which: 'paneB' | 'old', key: string): Promise<Pressed> {
await page.evaluate((k) => {
const w = window as any;
w[k === 'paneB' ? '__paneBData' : '__oldData'].length = 0;
w.__sendKeys.length = 0;
(k === 'paneB' ? w.__paneB.terminal : w.__oldTerm).focus();
}, which);
const focused = await page.evaluate((k) => {
const w = window as any;
return document.activeElement === (k === 'paneB' ? w.__paneB.terminal : w.__oldTerm).textarea;
}, which);
await page.keyboard.press(key);
await page.waitForTimeout(50);
return page.evaluate(
({ k, f }) => {
const w = window as any;
return { data: [...w[k === 'paneB' ? '__paneBData' : '__oldData']], sendKeys: [...w.__sendKeys], focused: f };
},
{ k: which, f: focused }
);
}
it('reproduces the leak on a bare xterm with the old keydown-only gate (the behaviour the fix depends on)', async () => {
// Not shipped code: this pins the xterm behaviour, so a future xterm that stops emitting the
// Shift-only keypress shows up here instead of silently turning the tests below vacuous.
const shift = await pressIn('old', 'Shift+Enter');
expect(shift.focused).toBe(true);
expect(shift.data).toEqual(['\r']);
expect((await pressIn('old', 'Control+Enter')).data).toEqual([]);
});
it('main pane (terminal-ui.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key exactly once', async () => {
const shift = await pressMain('Shift+Enter', MAIN_ID);
expect(shift.focused).toBe(true);
expect(shift.data).toEqual([]);
// Once, from the keydown: the swallowed keypress and keyup must not send again.
expect(shift.sendKeys).toEqual([`${MAIN_ID} S-Enter`]);
const ctrl = await pressMain('Control+Enter', MAIN_ID);
expect(ctrl.data).toEqual([]);
expect(ctrl.sendKeys).toEqual([`${MAIN_ID} C-Enter`]);
});
it('main pane: plain Enter still reaches the send path as \\r, and Alt+Enter as ESC CR', async () => {
const enter = await pressMain('Enter', MAIN_ID);
expect(enter.focused).toBe(true);
expect(enter.data).toEqual(['\r']);
expect(enter.sendKeys).toEqual([]);
const alt = await pressMain('Alt+Enter', MAIN_ID);
expect(alt.data).toEqual(['\x1b\r']);
expect(alt.sendKeys).toEqual([]);
});
it('main pane on the welcome screen: Shift+Enter sends nothing and posts no send-key', async () => {
// With no active session the app's onData drops input anyway, so this pins the send-key
// guard (`this.activeSessionId`), not the keypress swallow; the tests above pin that.
const shift = await pressMain('Shift+Enter', null);
expect(shift.focused).toBe(true);
expect(shift.data).toEqual([]);
expect(shift.sendKeys).toEqual([]);
});
it("Pane B (terminal-split.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
const shift = await pressIn('paneB', 'Shift+Enter');
expect(shift.focused).toBe(true);
expect(shift.data).toEqual([]);
expect(shift.sendKeys).toEqual([`${PANE_B_ID} S-Enter`]);
const ctrl = await pressIn('paneB', 'Control+Enter');
expect(ctrl.data).toEqual([]);
expect(ctrl.sendKeys).toEqual([`${PANE_B_ID} C-Enter`]);
const enter = await pressIn('paneB', 'Enter');
expect(enter.data).toEqual(['\r']);
expect(enter.sendKeys).toEqual([]);
});
});
+260 -36
View File
@@ -71,6 +71,8 @@ type PaneUnderTest = {
const fetchMock = vi.fn();
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
const rafQueue: Array<() => void> = [];
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8');
function loadSplitTerminalPane() {
@@ -80,9 +82,27 @@ function loadSplitTerminalPane() {
// compares it with the pane's own row count.
window: {
app: { _estimateReplayRows: (text: string) => text.split('\n').length },
AbortSignal: { timeout: (ms: number) => ({ timeoutMs: ms }) },
// The primary pane's capture budget (constants.js): the full-capture default.
CodemanFetchDeadline: { terminalFetchDeadlineMs: () => 45_000 },
AbortController: class {
signal = { aborted: false };
abort() {
this.signal.aborted = true;
}
},
},
performance: { now: () => clock },
// Deadline timers (>= 1 s) are recorded, never run: a test fires one by hand
// and reads what it aborted. Anything shorter (xterm chunk pacing) is real.
setTimeout: (fn: () => void, ms?: number) => {
if ((ms ?? 0) < 1000) return setTimeout(fn, ms);
deadlines.push({ fn, ms: ms as number, cleared: false });
return -deadlines.length; // negative: never collides with a real timer id
},
clearTimeout: (id: unknown) => {
if (typeof id === 'number' && id < 0) deadlines[-id - 1].cleared = true;
else clearTimeout(id as Parameters<typeof clearTimeout>[0]);
},
fetch: (...args: unknown[]) => fetchMock(...args),
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
// The constants.js globals the module reads at call time.
@@ -126,6 +146,27 @@ function jsonResponse(terminalBuffer: string, extra: Record<string, unknown> = {
return { json: async () => ({ data: { terminalBuffer, ...extra } }) };
}
/**
* A response whose headers have landed but whose body has not: the window in
* which the pull's live queue is open and nothing else has happened yet.
* `release(buffer)` delivers the body; `fail()` errors the body read.
*/
function headersOnly() {
let release!: (body: ReturnType<typeof jsonResponse> | Error) => void;
const body = new Promise<{ data: Record<string, unknown> }>((resolve, reject) => {
release = (value) => {
if (value instanceof Error) reject(value);
else void value.json().then(resolve);
};
});
return {
response: { json: () => body },
release: (terminalBuffer: string, extra: Record<string, unknown> = {}) =>
release(jsonResponse(terminalBuffer, extra)),
fail: () => release(new Error('body read failed')),
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
@@ -142,6 +183,7 @@ const settle = () => new Promise((r) => setTimeout(r, 0));
beforeEach(() => {
fetchMock.mockReset();
rafQueue.length = 0;
deadlines.length = 0;
clock = 0;
});
@@ -301,10 +343,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
pane._maybeLoadMoreHistory();
await settle();
// With a deadline: live output is held for as long as the pull runs, so a
// request that never answers would freeze the pane.
// With a deadline, so a request that never answers cannot pin the pane.
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
signal: { timeoutMs: 10_000 },
signal: expect.objectContaining({ aborted: false }),
});
expect(term.write).toHaveBeenCalledWith('\x1bc');
expect(term.write).toHaveBeenCalledWith(rowsOf(100));
@@ -463,12 +504,14 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
fetchMock.mockReturnValueOnce(response.promise);
pane._maybeLoadMoreHistory();
expect(pane._liveQueue).toEqual([]);
// The queue opens with the response, not the request.
expect(pane._liveQueue).toBeNull();
// Arrives before the response does: it is IN the capture already.
// Arrives before the response does: written straight through (the pane
// keeps painting during the round trip), and the replay then replaces it.
clock = 1;
pane._onLiveOutput('early');
expect(term.write).not.toHaveBeenCalledWith('early');
expect(term.write).toHaveBeenCalledWith('early');
await settle();
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
@@ -490,7 +533,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
await settle();
const written = term.write.mock.calls.map((call) => call[0]);
expect(written).not.toContain('early');
// 'early' went out before the reset, so the replay wiped it and it is not repeated.
expect(written.indexOf('early')).toBeLessThan(written.indexOf('\x1bc'));
expect(written.filter((w) => w === 'early')).toHaveLength(1);
expect(written.at(-1)).toBe('late');
expect(pane._liveQueue).toBeNull();
expect(pane._bufferLoading).toBe(false);
@@ -498,26 +543,29 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
it('writes every held frame when the pull ends without replaying', async () => {
const pane = makePane('shell');
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response);
pane._maybeLoadMoreHistory();
pane._onLiveOutput('held');
await settle();
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
pane._onLiveOutput('held');
expect(pane.terminal.write).not.toHaveBeenCalledWith('held');
held.release(rowsOf(30)); // nothing to gain: no replay
await settle();
// Nothing replaced the terminal, so the frame is news even though it
// arrived before the response did.
// Nothing replaced the terminal, so the held frame is news.
expect(pane.terminal.write).toHaveBeenCalledWith('held');
});
it('a failed fetch releases the flag and the queue, so live output flows again', async () => {
const pane = makePane('shell');
fetchMock.mockRejectedValueOnce(new Error('offline'));
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response);
pane._maybeLoadMoreHistory();
await settle();
pane._onLiveOutput('held');
held.fail(); // the body read dies with the queue open
await settle();
expect(pane._bufferLoading).toBe(false);
@@ -554,17 +602,18 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
done?.();
});
term.clear.mockImplementation(() => order.push('clear'));
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response);
pane._maybeLoadMoreHistory();
await settle();
pane._onLiveOutput('before');
pane._onLiveClear();
pane._onLiveOutput('after');
// Held: clearing now would wipe a half-written snapshot.
expect(order).toEqual([]);
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
held.release(rowsOf(30)); // nothing to gain: no replay
await settle();
expect(order).toEqual(['write:before', 'clear', 'write:after']);
@@ -583,25 +632,27 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
pane._maybeLoadMoreHistory();
clock = 1;
pane._onLiveClear(); // already reflected in the capture
pane._onLiveClear(); // before the response: applied now, already in the capture
expect(term.clear).toHaveBeenCalledTimes(1);
clock = 2;
response.resolve(jsonResponse(rowsOf(100)));
await settle();
expect(term.write).toHaveBeenCalledWith('\x1bc');
expect(term.clear).not.toHaveBeenCalled();
expect(term.clear).toHaveBeenCalledTimes(1); // not replayed after the capture
});
it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => {
const pane = makePane('shell');
const term = pane.terminal;
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response);
pane._maybeLoadMoreHistory();
await settle();
pane._onLiveOutput('held');
pane.destroy();
response.resolve(jsonResponse(rowsOf(100)));
held.release(rowsOf(100));
await settle();
expect(pane._bufferLoading).toBe(false);
@@ -613,10 +664,13 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
it('a pull whose request is aborted (the deadline) frees the pane', async () => {
const pane = makePane('shell');
fetchMock.mockRejectedValueOnce(new Error('The operation timed out'));
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response);
pane._maybeLoadMoreHistory();
await settle();
pane._onLiveOutput('held');
held.fail(); // the deadline aborts the body read
await settle();
expect(pane._bufferLoading).toBe(false);
@@ -698,18 +752,18 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
});
it('writes the disconnected marker once, after the replay, if the socket closes mid-fetch', async () => {
// The close lands while the capture is in flight, so the HTTP pull still
it('ends on the disconnected marker if the socket closes mid-fetch, before the queue opens', async () => {
// The close lands while the request is in flight, so the HTTP pull still
// succeeds (a Codeman restart drops the WS while the tmux session, and so
// the pull, survives) and the replay that follows is what the marker must
// end up below.
// the pull, survives). The marker waits for the load to finish, then goes
// below the replay.
const pane = makePane('shell');
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
const pull = pane._pullHistory();
pane._onSocketClosed(); // the close arrives mid-fetch, before the response
expect(pane.terminal.write).not.toHaveBeenCalled();
pane._onSocketClosed();
expect(pane.terminal.write.mock.calls.map((c) => c[0]).filter(isMarker)).toHaveLength(0);
response.resolve(jsonResponse(rowsOf(100)));
await pull;
@@ -718,25 +772,105 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
expect(isMarker(writes.at(-1))).toBe(true);
});
it('a close mid-fetch that ends without a replay still writes exactly one marker', async () => {
const pane = makePane('shell');
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
const pull = pane._pullHistory();
pane._onSocketClosed();
response.resolve(jsonResponse(rowsOf(30))); // already held in full: no replay
await pull;
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(writes.filter(isMarker)).toHaveLength(1);
});
it('a close during a refresh load lands the marker below the replay, not above it', async () => {
// A {t:'r'} refresh clears and refetches; a close mid-fetch used to write
// the marker at once, and the replay then landed underneath it.
const pane = makePane('shell');
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._refreshBuffer();
pane._onSocketClosed();
response.resolve(jsonResponse('refreshed'));
await settle();
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(writes.indexOf('refreshed')).toBeLessThan(writes.findIndex(isMarker));
expect(isMarker(writes.at(-1))).toBe(true);
expect(writes.filter(isMarker)).toHaveLength(1);
});
it('back-to-back refreshes on a closed socket leave exactly one marker, at the end', async () => {
// R1's finally runs the trailing refresh R2, so R1 leaves the owed marker to
// R2 instead of stamping it: in real xterm R1's write would still be queued
// when R2's synchronous clear() runs, and would land above R2's replay. The
// write mock records every stamp whatever clear() does, so counting marker
// writes pins that R1 never stamps (the async-parse test below shows why).
const pane = makePane('shell');
pane._wsClosed = true;
const first = deferred<ReturnType<typeof jsonResponse>>();
const second = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
pane._refreshBuffer(); // R1
pane._refreshBuffer(); // coalesced into the trailing R2
first.resolve(jsonResponse('first'));
await settle();
// R1 settled its marker, then R2 cleared and is still fetching.
expect(pane.terminal.clear).toHaveBeenCalledTimes(2);
second.resolve(jsonResponse('second'));
await settle();
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(writes.at(-1)).toSatisfy(isMarker);
expect(writes.lastIndexOf('second')).toBe(writes.length - 2);
expect(writes.filter(isMarker)).toHaveLength(1);
});
it('the pull gives the request the long budget and the body read the short one', async () => {
const pane = makePane('shell');
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response);
const pull = pane._pullHistory();
expect(deadlines).toHaveLength(1);
expect(deadlines[0].ms).toBe(45_000);
await settle(); // headers landed
expect(deadlines).toHaveLength(2);
expect(deadlines[0].cleared).toBe(true);
expect(deadlines[1].ms).toBe(10_000);
held.release(rowsOf(30));
await pull;
expect(deadlines[1].cleared).toBe(true); // nothing left to abort a settled pull
});
it.each([
['a skip', 40, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)))],
['a downgrade', 500, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)))],
['a failed fetch', 40, () => fetchMock.mockRejectedValueOnce(new Error('offline'))],
['a skip', 40, (h: ReturnType<typeof headersOnly>) => h.release(rowsOf(30))],
['a downgrade', 500, (h: ReturnType<typeof headersOnly>) => h.release(rowsOf(5))],
['a failed body read', 40, (h: ReturnType<typeof headersOnly>) => h.fail()],
])(
'a close mid-fetch that ends in %s writes the marker last, after the held frames',
async (_label, rowsHeld, mockFetch) => {
'a close with the queue open that ends in %s writes the marker last, after the held frames',
async (_label, rowsHeld, finish) => {
// No replay ever runs here, so nothing would wipe a marker written at the
// close; written straight away it sat ABOVE the output the pull was still
// holding, which the finally block then flushed underneath it.
const pane = makePane('shell');
pane.terminal.buffer.active.length = rowsHeld;
mockFetch();
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response);
const pull = pane._pullHistory();
await settle(); // the response landed: the queue is open
pane._onLiveOutput('frame-A');
pane._onLiveOutput('frame-B');
pane._onSocketClosed();
expect(pane.terminal.write).not.toHaveBeenCalled();
finish(held);
await pull;
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
@@ -771,6 +905,96 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
expect(isMarker(writes.at(-1))).toBe(true);
});
it('a refresh queued behind a pull on a closed socket does not wipe the marker', async () => {
// The refresh's clear() runs after the pull's finally block has written the
// marker, so without a re-stamp the dead pane would look current again.
const pane = makePane('shell');
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('refreshed'));
const pull = pane._pullHistory();
await settle();
pane._refreshBuffer(); // coalesced into one trailing re-run
pane._onSocketClosed(); // deferred: the queue is open
held.release(rowsOf(30)); // no replay
await pull;
await settle();
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(writes).toContain('refreshed');
expect(isMarker(writes.at(-1))).toBe(true);
expect(writes.lastIndexOf('refreshed')).toBeLessThan(writes.length - 1);
// The pull left the owed marker to the refresh rather than stamping it too.
expect(writes.filter(isMarker)).toHaveLength(1);
});
it.each([
[
'back-to-back refreshes',
async (pane: PaneUnderTest) => {
pane._wsClosed = true;
fetchMock.mockResolvedValueOnce(jsonResponse('first')).mockResolvedValueOnce(jsonResponse('second'));
pane._refreshBuffer();
pane._refreshBuffer(); // coalesced into the trailing re-run
},
],
[
'a pull with a queued refresh and a close mid-pull',
async (pane: PaneUnderTest) => {
const held = headersOnly();
fetchMock.mockResolvedValueOnce(held.response).mockResolvedValueOnce(jsonResponse('second'));
void pane._pullHistory();
await settle(); // the response landed: the queue is open
pane._refreshBuffer(); // coalesced into the trailing re-run
pane._onSocketClosed();
held.release(rowsOf(30)); // no replay
},
],
])('with xterm parsing writes on a later tick, %s leave one marker on screen, last', async (_label, drive) => {
// Real xterm queues write() and parses it on a later tick (WriteBuffer's
// setTimeout), while clear() rewrites the buffer at once. The default fake
// applies writes synchronously and so cannot show a marker overtaken by a
// trailing refresh's clear(): parsed after it, that marker sat above the
// refresh's replay as a second, stale copy.
const pane = makePane('shell');
const screen: string[] = [];
const pending: Array<{ data: string; done?: () => void }> = [];
pane.terminal.write = vi.fn((data: string, done?: () => void) => {
if (pending.length === 0) {
setTimeout(() => {
for (const entry of pending.splice(0)) {
if (entry.data === '\x1bc') screen.length = 0;
else if (entry.data) screen.push(entry.data);
entry.done?.();
}
}, 0);
}
pending.push({ data, done });
});
pane.terminal.clear = vi.fn(() => {
screen.length = 0;
});
await drive(pane);
for (let i = 0; i < 5; i++) await settle();
expect(pane._bufferLoading).toBe(false);
expect(screen.filter(isMarker)).toHaveLength(1);
expect(screen.at(-1)).toSatisfy(isMarker);
expect(screen.indexOf('second')).toBe(screen.length - 2);
});
it('a refresh on an open socket does not stamp a marker', async () => {
const pane = makePane('shell');
fetchMock.mockResolvedValueOnce(jsonResponse('refreshed'));
pane._refreshBuffer();
await settle();
expect(pane.terminal.write.mock.calls.map((c) => c[0]).filter(isMarker)).toHaveLength(0);
});
it('does not re-stamp the marker when the socket is still open', async () => {
const pane = makePane('shell');
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
+387
View File
@@ -0,0 +1,387 @@
/**
* @fileoverview Real-Chromium coverage for tab-row activation and the grouped rail tree.
*
* A tab row is one activation target: its status dot, mode chip, name and unused
* padding all select it, while its own controls (gear, detach, close, the rail's
* overflow button, a web tab's gear and close) run only their own action. The
* controls must also keep a stable hit target: revealing one on hover may not
* slide its neighbours out from under a pointer already aiming at them.
*
* DOM emulation cannot answer either question (hit testing, hover reveal and
* layout are Chromium's), which is why this runs in a real browser. The shipping
* app.js, webview-tabs.js, terminal-ui.js (for the touch keyboard dismissal) and
* styles.css are loaded into a page; the CodemanApp instance gets stub actions
* that only record what ran.
*
* Port: none (page.setContent, no server).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
describe('tab row activation in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
// A real origin, so localStorage (per-device collapse) works as it does in the app.
await page.route('http://codeman.test/', (route) =>
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
);
await page.goto('http://codeman.test/');
await page.setContent(`<!doctype html>
<html data-tab-orientation="horizontal" data-tab-rail-sort="manual">
<head><style>${read('styles.css')}</style></head>
<body>
<header class="header"><div id="sessionTabsHost"></div></header>
<main class="main" style="width:100%;height:700px">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation"></aside>
</main>
</body>
</html>`);
await page.addScriptTag({
content:
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('tab-layout-browser.js') +
'\n' +
read('app.js') +
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
});
await page.addScriptTag({ content: read('webview-tabs.js') });
await page.addScriptTag({ content: read('terminal-ui.js') });
await page.evaluate(() => {
const w = window as any;
const app = Object.create(w.CodemanApp.prototype);
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
['one', { id: 'one', name: 'One', status: 'idle', mode: 'shell' }],
['two', { id: 'two', name: 'Two', status: 'idle' }],
]);
app.sessionOrder = ['one', 'two'];
app.webviews = new Map([['web', { id: 'web', name: 'Web', url: 'https://example.test', icon: 'W' }]]);
app.webviewOrder = ['web'];
app.activeSessionId = 'one';
app.activeWebviewId = null;
app.tabLayout = null;
app.collapsedTabGroupIds = new Set();
app._hiddenTabGroupByRef = new Map();
app._lastTabGroupStructureKey = null;
app._inlineRenameActive = false;
app.tabAlerts = new Map();
app.terminalLoadStates = new Map();
app.minimizedSubagents = new Map();
app.hasTabDetachOverride = () => true;
app.renderSubagentTabBadge = () => '';
app.cancelHideSubagentDropdown = () => undefined;
app.updateTabOverflowMode = () => undefined;
app.updateConnectionLines = () => undefined;
app._applyTabEntrances = () => undefined;
app._scrollActiveTabIntoView = () => undefined;
app.applySidebarFilter = () => undefined;
app.isSessionSidebarActive = () => false;
app._startSidebarRichClock = () => undefined;
app._stopSidebarRichClock = () => undefined;
const record = (key: string, value: string) => () => {
w[key] = value;
};
app.selectSession = (id: string) => {
w.__activation = `session:${id}`;
};
app.openWebview = (id: string) => {
w.__activation = `webview:${id}`;
};
app.openSessionOptions = record('__action', 'settings');
app.detachSession = record('__action', 'detach');
app.requestCloseSession = record('__action', 'close');
app.openTabRailActionMenu = record('__action', 'overflow');
app.showWebviewModal = record('__action', 'web-settings');
app.closeWebviewTab = record('__action', 'web-close');
w.__setApp(app);
w.__app = app;
});
});
afterAll(async () => browser.close());
/** Render the strip/rail in a given shape, from scratch. */
async function render(orientation: 'horizontal' | 'vertical', grouped = false) {
await page.evaluate(
({ orientation, grouped }) => {
const w = window as any;
document.documentElement.setAttribute('data-tab-orientation', orientation);
const host =
orientation === 'vertical'
? document.getElementById('tabRail')!
: document.getElementById('sessionTabsHost')!;
document.getElementById('sessionTabs')?.remove();
host.insertAdjacentHTML(
'afterbegin',
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
);
w.__app._tabKeydownHandler = null;
w.__app.activeSessionId = 'one';
w.__app.activeWebviewId = null;
w.__app.tabLayout = grouped
? {
version: 1,
groups: [
{
id: 'g',
name: 'Group',
refs: [
{ kind: 'session', id: 'one' },
{ kind: 'webview', id: 'web' },
],
},
],
ungrouped: [{ kind: 'session', id: 'two' }],
}
: null;
w.__app._fullRenderSessionTabs();
w.__activation = null;
w.__action = null;
},
{ orientation, grouped }
);
await page.mouse.move(1, 1);
}
const result = () =>
page.evaluate(() => ({ activation: (window as any).__activation, action: (window as any).__action }));
const reset = () => page.evaluate(() => ((window as any).__activation = (window as any).__action = null));
for (const [label, orientation, grouped] of [
['horizontal strip', 'horizontal', false],
['flat vertical rail', 'vertical', false],
['grouped vertical rail', 'vertical', true],
] as const) {
it(`activates a row from its status, mode, name and padding (${label})`, async () => {
await render(orientation, grouped);
for (const [selector, expected] of [
['[data-id="two"] .tab-status', 'session:two'],
['[data-id="two"] .tab-name', 'session:two'],
['[data-id="one"] .tab-mode', 'session:one'],
['[data-webview-id="web"] .tab-name', 'webview:web'],
] as const) {
await reset();
await page.locator(selector).click();
expect((await result()).activation, selector).toBe(expected);
}
for (const [selector, expected] of [
['[data-id="two"]', 'session:two'],
['[data-webview-id="web"]', 'webview:web'],
] as const) {
await reset();
const box = (await page.locator(selector).boundingBox())!;
await page.mouse.click(box.x + 2, box.y + box.height / 2);
expect((await result()).activation, `${selector} leading padding`).toBe(expected);
// The cursor advertises the whole row: a pointer, or the grab hand where
// the row is also a drag-reorder handle (the flat lists).
const { cursor, draggable } = await page
.locator(selector)
.evaluate((el) => ({ cursor: getComputedStyle(el).cursor, draggable: el.getAttribute('draggable') }));
expect(cursor, selector).toBe(draggable === 'true' ? 'grab' : 'pointer');
}
});
it(`runs a control's own action without selecting the row (${label})`, async () => {
await render(orientation, grouped);
for (const [selector, expected] of [
['[data-id="one"] .tab-gear', 'settings'],
['[data-id="one"] .tab-detach', 'detach'],
['[data-id="one"] .tab-close', 'close'],
] as const) {
await reset();
const control = page.locator(selector);
expect(await control.isVisible(), selector).toBe(true);
expect(await control.evaluate((el) => getComputedStyle(el).cursor), selector).toBe('pointer');
await control.click();
expect(await result(), selector).toEqual({ activation: null, action: expected });
}
if (orientation === 'vertical') {
await reset();
await page.locator('[data-id="one"] .tab-more').click();
expect(await result()).toEqual({ activation: null, action: 'overflow' });
}
await page.evaluate(() => {
(window as any).__app.activeWebviewId = 'web';
(window as any).__app._fullRenderSessionTabs();
});
for (const [selector, expected] of [
['[data-webview-id="web"] .tab-gear', 'web-settings'],
['[data-webview-id="web"] .tab-close', 'web-close'],
] as const) {
await reset();
await page.locator(selector).click();
expect(await result(), selector).toEqual({ activation: null, action: expected });
}
});
it(`keeps action controls anchored under the pointer when a row is hovered (${label})`, async () => {
// Revealing a control on hover must not reflow its row: a button that
// toggled `display` on hover once slid the gear 28px left, so the click
// aimed at the gear landed on the close button instead. Assert the
// geometry, not a CSS property, so any reflow-on-hover rewrite fails here.
await render(orientation, grouped);
for (const selector of ['[data-id="one"] .tab-gear', '[data-id="one"] .tab-close']) {
await page.mouse.move(1, 1);
const control = page.locator(selector);
const before = (await control.boundingBox())!;
const aim = { x: Math.round(before.x + before.width / 2), y: Math.round(before.y + before.height / 2) };
await page.mouse.move(aim.x, aim.y);
await page.waitForTimeout(250);
const after = (await control.boundingBox())!;
expect(Math.abs(after.x - before.x), selector).toBeLessThanOrEqual(4);
const stillOnTarget = await page.evaluate(
({ point, selector }) => !!document.elementFromPoint(point.x, point.y)?.closest(selector),
{ point: aim, selector }
);
expect(stillOnTarget, selector).toBe(true);
}
});
}
it('toggles a group from its header without selecting anything', async () => {
await render('vertical', true);
const header = page.locator('[data-tab-group-header="g"]');
await header.click();
expect(await result()).toEqual({ activation: null, action: null });
expect(await header.getAttribute('aria-expanded')).toBe('false');
await page.locator('[data-tab-group-header="g"]').click();
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('true');
});
it('walks the grouped rail tree from the keyboard with one tab stop', async () => {
await render('vertical', true);
const tree = page.locator('#sessionTabs[role="tree"]');
expect(await tree.count()).toBe(1);
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
// The roving tab stop starts on the selected row.
await page.keyboard.press('Tab');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('one');
await page.keyboard.press('ArrowLeft');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
await page.keyboard.press('ArrowLeft');
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('false');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
await page.keyboard.press('ArrowRight');
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('true');
await page.keyboard.press('ArrowRight');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('one');
await page.keyboard.press('End');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('two');
await page.keyboard.press('Home');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-webview-id'))).toBe('web');
await reset();
await page.keyboard.press('Enter');
expect((await result()).activation).toBe('webview:web');
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
});
it('leaves Enter on a focused in-row control to that control in the tree', async () => {
await render('vertical', true);
// A click focuses the overflow button (the action menu hands focus back the
// same way on Escape). Enter there must run the button, not re-select the row.
await page.locator('[data-id="one"] .tab-more').click();
expect(await page.evaluate(() => document.activeElement?.classList.contains('tab-more'))).toBe(true);
await reset();
await page.keyboard.press('Enter');
expect(await result()).toEqual({ activation: null, action: 'overflow' });
await page.keyboard.press('ArrowDown');
expect(await page.evaluate(() => document.activeElement?.classList.contains('tab-more'))).toBe(true);
});
it('keeps the touch keyboard up when a tree header or unselected row is tapped', async () => {
await render('vertical', true);
const state = await page.evaluate(() => {
const w = window as any;
const app = w.__app;
const input = document.createElement('textarea');
input.className = 'xterm-helper-textarea';
document.body.appendChild(input);
app._installMobileKeyboardDismiss();
// A finger that lifts where it landed (no travel): the handler reads only
// `touches`, so a plain Event carrying them stands in for a TouchEvent.
const tap = (target: Element) => {
const at = (type: string, touches: Array<{ clientX: number; clientY: number }>) => {
const ev = new Event(type, { bubbles: true });
Object.defineProperty(ev, 'touches', { value: touches });
target.dispatchEvent(ev);
};
at('touchstart', [{ clientX: 10, clientY: 10 }]);
at('touchend', []);
};
const keptAfterTap = (selector: string) => {
input.focus();
tap(document.querySelector(selector)!);
return document.activeElement === input;
};
const out = {
header: keptAfterTap('[data-tab-group-header="g"] .tab-layout-group-name'),
unselectedRow: keptAfterTap('[data-webview-id="web"] .tab-name'),
inertChrome: false,
};
// Inert chrome still dismisses, so the handler is live.
out.inertChrome = !keptAfterTap('.tab-layout-ungrouped-header');
document.removeEventListener('touchstart', app._mobileKeyboardDismissStart);
document.removeEventListener('touchmove', app._mobileKeyboardDismissMove);
document.removeEventListener('touchend', app._mobileKeyboardDismissHandler);
app._mobileKeyboardDismissHandler = null;
input.remove();
return out;
});
expect(state).toEqual({ header: true, unselectedRow: true, inertChrome: true });
});
it('rings a collapsed header in the tab alert colour of the row it hides', async () => {
await render('vertical', true);
const ring = await page.evaluate(() => {
const app = (window as any).__app;
// "two" is the selection, so collapsing the group hides "one".
app.activeSessionId = 'two';
app.tabAlerts.set('one', 'action');
app.toggleTabGroupCollapsed('g', true);
const header = document.querySelector('[data-tab-group-header="g"]')!;
const before = getComputedStyle(header, '::before');
const out = {
hidden: !document.querySelector('[data-id="one"]'),
className: header.classList.contains('tab-alert-action'),
content: before.content,
borderColor: before.borderTopColor,
};
app.tabAlerts.clear();
app.toggleTabGroupCollapsed('g', false);
return out;
});
expect(ring.hidden).toBe(true);
expect(ring.className).toBe(true);
expect(ring.content).not.toBe('none');
// --red, whatever the skin resolves it to: a real colour, not transparent.
expect(ring.borderColor).not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/);
});
it('keeps tab semantics and no tree roles without groups', async () => {
await render('vertical', false);
expect(await page.locator('#sessionTabs').getAttribute('role')).toBe('tablist');
expect(
await page
.locator('#sessionTabs [role="tree"], #sessionTabs [role="treeitem"], #sessionTabs [role="group"]')
.count()
).toBe(0);
expect(await page.locator('#sessionTabs .session-tab[role="tab"]').count()).toBe(3);
});
});
+458
View File
@@ -0,0 +1,458 @@
/**
* @fileoverview Browser projection of the owner tab layout (tab-layout-browser.js).
*
* Pins the pure half of the grouped vertical rail: which live rows land in which
* group, what a collapsed group hides (and the one row it must keep showing),
* the per-device collapse storage, the grouped markup, and newest-wins loading.
*
* Port: none (vm-loaded module + static wiring assertions).
*/
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const SOURCE = readFileSync(new URL('../src/web/public/tab-layout-browser.js', import.meta.url), 'utf8');
const APP_SOURCE = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf8');
const INDEX_SOURCE = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8');
const BUILD_SOURCE = readFileSync(new URL('../scripts/build.mjs', import.meta.url), 'utf8');
type Ref = { kind: 'session' | 'webview'; id: string };
function loadHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(SOURCE, context, { filename: 'tab-layout-browser.js' });
return (context.window as any).CodemanTabLayout;
}
const escape = (value: string) =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const layout = (version = 4) => ({
version,
updatedAt: '2026-08-16T00:00:00.000Z',
groups: [
{
id: 'g1',
name: '<Core & Ops>',
refs: [
{ kind: 'session', id: 's2' },
{ kind: 'webview', id: 'w1' },
{ kind: 'session', id: 's1' },
{ kind: 'webview', id: 'saved-closed' },
{ kind: 'session', id: 'gone' },
],
},
{ id: 'g2', name: 'Later', refs: [{ kind: 'session', id: 's3' }] },
],
ungrouped: [
{ kind: 'webview', id: 'w2' },
{ kind: 'session', id: 's4' },
{ kind: 'session', id: 's2' },
],
});
const ids = (refs: Ref[]) => refs.map((ref) => `${ref.kind}:${ref.id}`);
describe('projection', () => {
it('returns null for a layout without groups, so the caller keeps the flat rail', () => {
const h = loadHelper();
expect(h.project({ version: 1, groups: [], ungrouped: [{ kind: 'session', id: 's1' }] }, {})).toBeNull();
expect(h.project(null, {})).toBeNull();
expect(h.hasGroups(h.normalizeLayout({ version: 1 }))).toBe(false);
});
it('places stored refs in their groups, skips gone/unopened refs, and deduplicates', () => {
const h = loadHelper();
const result = h.project(layout(), {
liveSessionIds: ['s1', 's2', 's3', 's4'],
openWebviewIds: ['w1', 'w2'],
});
expect(result.sections.map((s: any) => [s.id, ids(s.refs), s.count])).toEqual([
['g1', ['session:s2', 'webview:w1', 'session:s1'], 3],
['g2', ['session:s3'], 1],
[null, ['webview:w2', 'session:s4'], 2],
]);
expect(new Set(ids(result.visibleRefs)).size).toBe(result.visibleRefs.length);
});
it('appends live sessions and open web tabs the layout has not placed, in caller order', () => {
const h = loadHelper();
const result = h.project(layout(), {
liveSessionIds: ['new-b', 's1', 'new-a'],
openWebviewIds: ['w-local'],
});
expect(ids(result.sections.at(-1).refs)).toEqual(['session:new-b', 'session:new-a', 'webview:w-local']);
});
it('omits an empty ungrouped section', () => {
const h = loadHelper();
const result = h.project(layout(), { liveSessionIds: ['s1', 's3'], openWebviewIds: [] });
expect(result.sections.map((s: any) => s.id)).toEqual(['g1', 'g2']);
});
it('hides a collapsed group but keeps its highlighted row, and records where hidden rows went', () => {
const h = loadHelper();
const common = {
liveSessionIds: ['s1', 's2', 's3', 's4'],
openWebviewIds: ['w1', 'w2'],
collapsedGroupIds: ['g1'],
};
const collapsed = h.project(layout(), { ...common, activeSessionId: 's1' });
expect(ids(collapsed.sections[0].refs)).toEqual(['session:s1']);
expect(collapsed.sections[0].count).toBe(3);
expect(collapsed.sections[0].collapsed).toBe(true);
expect(collapsed.hiddenTabGroupByRef).toEqual({ 'session:s2': 'g1', 'webview:w1': 'g1' });
expect(ids(collapsed.sections[0].hidden)).toEqual(['session:s2', 'webview:w1']);
// Every placed row knows its section, shown or hidden (null = Ungrouped).
expect(collapsed.sectionByRef).toEqual({
'session:s2': 'g1',
'webview:w1': 'g1',
'session:s1': 'g1',
'session:s3': 'g2',
'webview:w2': null,
'session:s4': null,
});
// An active web tab owns the highlight even while a session stays selected.
const web = h.project(layout(), { ...common, activeSessionId: 's1', activeWebviewId: 'w1' });
expect(ids(web.sections[0].refs)).toEqual(['webview:w1']);
});
});
describe('structure key', () => {
it('changes with every structural input and ignores everything else', () => {
const h = loadHelper();
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
const base = h.structureKey(layout(), h.project(layout(), opts), []);
expect(h.structureKey(layout(), h.project(layout(), opts), [])).toBe(base);
expect(h.structureKey(layout(), h.project(layout(), { ...opts, collapsedGroupIds: ['g1'] }), ['g1'])).not.toBe(
base
);
expect(h.structureKey(layout(), h.project(layout(), { ...opts, liveSessionIds: ['s1', 's2'] }), [])).not.toBe(base);
const renamed = { ...layout(), groups: [{ ...layout().groups[0], name: 'Renamed' }, layout().groups[1]] };
expect(h.structureKey(renamed, h.project(renamed, opts), [])).not.toBe(base);
expect(h.structureKey(layout(), null, [])).toBeNull();
});
it('ignores a version bump that moves nothing, so a lifecycle broadcast costs no rebuild', () => {
const h = loadHelper();
const opts = { liveSessionIds: ['s1', 's2', 's3'], openWebviewIds: [] };
expect(h.structureKey(layout(5), h.project(layout(5), opts), [])).toBe(
h.structureKey(layout(4), h.project(layout(4), opts), [])
);
});
it('changes when a collapse hides a different row, even with equal counts and shown rows', () => {
const h = loadHelper();
const two = (refs: Ref[][]) => ({
version: 1,
groups: [
{ id: 'a', name: 'A', refs: refs[0] },
{ id: 'b', name: 'B', refs: refs[1] },
],
ungrouped: [],
});
const s = (id: string): Ref => ({ kind: 'session', id });
const opts = { liveSessionIds: ['x', 'y'], openWebviewIds: [], collapsedGroupIds: ['a', 'b'] };
const before = two([[s('x')], [s('y')]]);
const after = two([[s('y')], [s('x')]]);
// Lineage anchors a hidden row to its header, so who hides where is structure.
expect(h.structureKey(after, h.project(after, opts), ['a', 'b'])).not.toBe(
h.structureKey(before, h.project(before, opts), ['a', 'b'])
);
});
});
describe('collapsed header alerts', () => {
it('reports the most urgent alert among the session rows a collapse hides', () => {
const h = loadHelper();
const alerts = new Map([
['s2', 'idle'],
['s1', 'action'],
['s3', 'idle'],
['s4', 'action'],
]);
const alertOf = (id: string) => alerts.get(id);
const common = { liveSessionIds: ['s1', 's2', 's3', 's4'], openWebviewIds: ['w1', 'w2'] };
// s1 is the kept selection, so it draws its own ring: only s2 (idle) is behind g1.
const kept = h.project(layout(), { ...common, collapsedGroupIds: ['g1', 'g2'], activeSessionId: 's1' });
expect(h.hiddenGroupAlerts(kept, alertOf)).toEqual({ g1: 'idle', g2: 'idle' });
// With s1 hidden too, the red one wins over the yellow one.
const hidden = h.project(layout(), { ...common, collapsedGroupIds: ['g1'], activeSessionId: 's4' });
expect(h.hiddenGroupAlerts(hidden, alertOf)).toEqual({ g1: 'action' });
// Expanded groups and the ungrouped section never report (their rows are on screen).
expect(h.hiddenGroupAlerts(h.project(layout(), common), alertOf)).toEqual({});
expect(h.hiddenGroupAlerts(null, alertOf)).toEqual({});
});
});
describe('per-device collapse storage', () => {
function storage(initial: string | null = null) {
const values = new Map<string, string>();
if (initial !== null) values.set('codeman:tab-groups-collapsed', initial);
return {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, next: string) => {
values.set(key, next);
}),
};
}
it('round-trips collapsed group ids', () => {
const h = loadHelper();
const local = storage();
expect(h.saveCollapsedGroupIds(local, ['g2', 'g1', 'g2'])).toEqual({ ids: ['g2', 'g1'], ok: true });
expect(h.loadCollapsedGroupIds(local, ['g1', 'g2'])).toEqual({ ids: ['g2', 'g1'], ok: true });
});
it('garbage-collects ids of groups that no longer exist', () => {
const h = loadHelper();
const local = storage('["gone","g1"]');
expect(h.loadCollapsedGroupIds(local, ['g1', 'g2'])).toEqual({ ids: ['g1'], ok: true });
expect(local.setItem).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '["g1"]');
});
it('reports failure (all-expanded) on unreadable or unwritable storage', () => {
const h = loadHelper();
const throwing = {
getItem: () => {
throw new Error('denied');
},
setItem: () => {
throw new Error('quota');
},
};
expect(h.loadCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
expect(h.saveCollapsedGroupIds(throwing, ['g1'])).toEqual({ ids: [], ok: false });
});
it('reads a malformed stored value as nothing collapsed and repairs it, so collapse keeps working', () => {
const h = loadHelper();
for (const bad of ['{"g1":true}', 'not json', '"g1"', 'null']) {
const local = storage(bad);
expect(h.loadCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: [], ok: true });
expect(local.setItem, bad).toHaveBeenLastCalledWith('codeman:tab-groups-collapsed', '[]');
expect(h.saveCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: ['g1'], ok: true });
expect(h.loadCollapsedGroupIds(local, ['g1']), bad).toEqual({ ids: ['g1'], ok: true });
}
// Without a group list to validate against, nothing is written.
const untouched = storage('{"g1":true}');
expect(h.loadCollapsedGroupIds(untouched)).toEqual({ ids: [], ok: true });
expect(untouched.setItem).not.toHaveBeenCalled();
});
});
describe('grouped markup', () => {
it('renders escaped group sections as a tree skeleton around caller-rendered rows', () => {
const h = loadHelper();
const projection = h.project(layout(), {
liveSessionIds: ['s1', 's2', 's3', 's4'],
openWebviewIds: ['w1', 'w2'],
collapsedGroupIds: ['g2'],
});
const html = h.renderProjection(
projection,
(ref: Ref) => `<div class="row" data-ref="${ref.kind}:${ref.id}"></div>`,
escape
);
const doc = new JSDOM(`<main>${html}</main>`).window.document;
expect([...doc.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([
'<Core & Ops>',
'Later',
'Ungrouped',
]);
expect(doc.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
expect(doc.querySelector('.tab-layout-group-name')?.hasAttribute('data-i18n-skip')).toBe(true);
// Sections are layout only; the caller's container is the tree.
expect([...doc.querySelectorAll('section')].every((el) => el.getAttribute('role') === 'presentation')).toBe(true);
// A named header is a level-1 treeitem that toggles and OWNS its rows' group.
const g1 = doc.querySelector<HTMLElement>('[data-tab-group-header="g1"]')!;
expect(g1.getAttribute('role')).toBe('treeitem');
expect(g1.getAttribute('tabindex')).toBe('-1');
expect(g1.getAttribute('aria-expanded')).toBe('true');
expect(g1.getAttribute('onclick')).toBe('app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)');
const owned = doc.getElementById(g1.getAttribute('aria-owns')!)!;
expect(owned.getAttribute('role')).toBe('group');
expect(doc.getElementById(owned.getAttribute('aria-labelledby')!)?.textContent).toBe('<Core & Ops>');
expect(owned.querySelectorAll('.row')).toHaveLength(3);
// No interactive element nested inside a treeitem.
expect(g1.querySelectorAll('button, [tabindex]')).toHaveLength(0);
// A collapsed header owns nothing, so its kept row cannot read as the child of a closed node.
const g2 = doc.querySelector<HTMLElement>('[data-tab-group-header="g2"]')!;
expect(g2.getAttribute('aria-expanded')).toBe('false');
expect(g2.hasAttribute('aria-owns')).toBe(false);
expect(g2.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe('presentation');
// The ungrouped heading is a visual divider: nothing to collapse, nothing to announce.
const ungrouped = doc.querySelector('.tab-layout-ungrouped-header')!;
expect(ungrouped.getAttribute('aria-hidden')).toBe('true');
expect(ungrouped.hasAttribute('role')).toBe(false);
expect(ungrouped.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe(
'presentation'
);
expect([...doc.querySelectorAll<HTMLElement>('.row')].map((el) => el.dataset.ref)).toEqual(
ids(projection.visibleRefs)
);
});
});
describe('empty groups', () => {
it('renders a group with no open rows as a tree leaf, expanded or collapsed', () => {
const h = loadHelper();
for (const collapsedGroupIds of [[], ['g2']]) {
const projection = h.project(layout(), { liveSessionIds: ['s1', 's2'], openWebviewIds: [], collapsedGroupIds });
expect(projection.sections[1]).toMatchObject({ id: 'g2', count: 0, refs: [] });
const html = h.renderProjection(projection, () => '', escape);
const doc = new JSDOM(`<main>${html}</main>`).window.document;
const header = doc.querySelector('[data-tab-group-header="g2"]')!;
expect(header.getAttribute('role')).toBe('treeitem');
expect(header.hasAttribute('aria-expanded')).toBe(false);
expect(header.hasAttribute('aria-owns')).toBe(false);
expect(header.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe(
'presentation'
);
// The populated group is unaffected.
expect(doc.querySelector('[data-tab-group-header="g1"]')!.getAttribute('aria-expanded')).toBe('true');
}
});
});
describe('load coordination', () => {
it('applies only the newest response when loads overlap', async () => {
const h = loadHelper();
const pending: Array<(value: unknown) => void> = [];
const applied: number[] = [];
const coordinator = h.createLoadCoordinator({
fetchLayout: () => new Promise((resolve) => pending.push(resolve)),
applyLayout: (value: any) => applied.push(value.version),
applyFallback: vi.fn(),
scheduleRetry: vi.fn(),
});
const first = coordinator.load();
const second = coordinator.load();
pending[1](layout(2));
await second;
pending[0](layout(1));
await first;
expect(applied).toEqual([2]);
});
it('falls back on failure, schedules one retry, and lets a successful retry replace the fallback', async () => {
const h = loadHelper();
const applyLayout = vi.fn();
const applyFallback = vi.fn();
let retry: (() => Promise<boolean>) | undefined;
let attempt = 0;
const coordinator = h.createLoadCoordinator({
fetchLayout: async () => {
attempt++;
if (attempt === 1) throw new Error('offline');
return layout(7);
},
applyLayout,
applyFallback,
scheduleRetry: (fn: () => Promise<boolean>) => {
retry = fn;
return 42;
},
cancelRetry: vi.fn(),
});
await coordinator.load();
expect(applyFallback).toHaveBeenCalledTimes(1);
expect(applyLayout).not.toHaveBeenCalled();
await retry!();
expect(applyLayout).toHaveBeenCalledWith(layout(7));
});
it('backs off failed retries, stops after the cap, and starts over after a success', async () => {
const h = loadHelper();
const delays: number[] = [];
let pending: (() => Promise<boolean>) | null = null;
let failing = true;
const applyFallback = vi.fn();
const coordinator = h.createLoadCoordinator({
fetchLayout: async () => {
if (failing) throw new Error('offline');
return layout(1);
},
applyLayout: vi.fn(),
applyFallback,
retryDelayMs: 5000,
maxRetryDelayMs: 30000,
maxRetries: 4,
scheduleRetry: (fn: () => Promise<boolean>, delay: number) => {
delays.push(delay);
pending = fn;
return delays.length;
},
cancelRetry: vi.fn(),
});
await coordinator.load();
// Bounded drain: an uncapped retry must fail here, not spin forever (the
// loop only awaits microtasks, so vitest's own timeout could never fire).
for (let drained = 0; pending && drained < 10; drained++) {
const next: () => Promise<boolean> = pending;
pending = null;
await next();
}
// Four retries (5 s doubling, capped at 30 s), then nothing more is scheduled.
expect(delays).toEqual([5000, 10000, 20000, 30000]);
expect(applyFallback).toHaveBeenCalledTimes(5);
// An outside load (SSE init, tab:layoutChanged) still tries, and a success
// resets the count, so the next outage gets the full schedule again.
failing = false;
expect(await coordinator.load()).toBe(true);
failing = true;
await coordinator.load();
expect(delays.at(-1)).toBe(5000);
coordinator.dispose();
});
it('cancels a pending retry and ignores in-flight results after dispose', async () => {
const h = loadHelper();
let resolve!: (value: unknown) => void;
const applyLayout = vi.fn();
const cancelRetry = vi.fn();
let calls = 0;
const coordinator = h.createLoadCoordinator({
fetchLayout: () => {
calls++;
if (calls === 1) return Promise.reject(new Error('offline'));
return new Promise((r) => (resolve = r));
},
applyLayout,
applyFallback: vi.fn(),
scheduleRetry: () => 7,
cancelRetry,
});
await coordinator.load();
const second = coordinator.load();
expect(cancelRetry).toHaveBeenCalledWith(7);
coordinator.dispose();
resolve(layout(3));
await second;
expect(applyLayout).not.toHaveBeenCalled();
});
});
describe('browser wiring', () => {
it('loads the helper before app.js, ships it through the build, and wires GET + SSE', () => {
expect(INDEX_SOURCE.indexOf('<script defer src="tab-layout-browser.js"')).toBeGreaterThan(-1);
expect(INDEX_SOURCE.indexOf('<script defer src="tab-layout-browser.js"')).toBeLessThan(
INDEX_SOURCE.indexOf('<script defer src="app.js"')
);
expect(BUILD_SOURCE).toContain("'tab-layout-browser.js'");
expect(APP_SOURCE).toContain("this._apiJson('/api/tab-layout')");
expect(APP_SOURCE).toContain("[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged']");
});
it('never writes the layout from the browser in this slice', () => {
expect(APP_SOURCE).not.toMatch(/['"`]PUT['"`][^\n]*tab-layout|tab-layout[^\n]*['"`]PUT['"`]/);
expect(SOURCE).not.toContain('fetch(');
});
});
+818
View File
@@ -0,0 +1,818 @@
/**
* @fileoverview The grouped vertical rail, driven through the shipping CodemanApp.
*
* Covers the app.js half of the owner-tab-layout frontend: the rail renders the
* owner's groups only when it is vertical AND there is at least one group (every
* other case must be byte-for-byte the flat rail), collapse is per-device and
* keeps the active row, a structural change escapes the incremental patch path,
* drag-reorder and cross-group Ctrl+Shift moves are withheld, a collapsed
* header carries the alert it hides, layout reads rebuild only on a structural
* change and back off when they fail, lineage arcs to a collapse-hidden session
* anchor to its group header, and the grouped rail (only) is an ARIA tree with
* one roving tab stop, a tree keyboard model and focus restored across rebuilds.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
* `document`, `localStorage` and `window` below are that window's, not Node's.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
let CodemanApp: { prototype: Record<string, any> };
let window: any;
let document: Document;
let localStorage: Storage;
let localStorageDescriptor: PropertyDescriptor | undefined;
beforeAll(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
// Evaluate after DOMContentLoaded so app.js's own bootstrap (new CodemanApp()
// on that event) never runs: each test builds the instance it needs.
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
window = dom.window;
document = window.document;
localStorage = window.localStorage;
localStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
window.setInterval = () => 0;
window.requestAnimationFrame = () => 0;
window.CSS = { escape: (value: string) => value };
window.eval(
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('tab-layout-browser.js') +
'\n' +
read('app.js') +
'\n' +
read('webview-tabs.js') +
'\n' +
read('session-lineage.js') +
'\n;window.__GroupedRailCodemanApp = CodemanApp;'
);
CodemanApp = window.__GroupedRailCodemanApp;
});
const layout = {
version: 8,
updatedAt: '2026-08-16T00:00:00.000Z',
groups: [
{
id: 'group-x',
name: '<Core & Ops>',
refs: [
{ kind: 'session', id: 's2' },
{ kind: 'webview', id: 'w1' },
{ kind: 'session', id: 's1' },
],
},
],
ungrouped: [{ kind: 'session', id: 's3' }],
};
function makeApp(options: { tabLayout?: unknown } = {}) {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
document.documentElement.dataset.tabRailSort = 'manual';
document.body.innerHTML = '<div id="sessionTabs"></div>';
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
['s1', { id: 's1', name: 'One', status: 'idle' }],
['s2', { id: 's2', name: 'Two', status: 'busy' }],
['s3', { id: 's3', name: 'Three', status: 'idle' }],
]);
app.sessionOrder = ['s1', 's2', 's3'];
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
app.webviewOrder = ['w1'];
app.activeSessionId = 's2';
app.activeWebviewId = null;
app.tabLayout = 'tabLayout' in options ? options.tabLayout : layout;
app.collapsedTabGroupIds = new Set();
app._hiddenTabGroupByRef = new Map();
app._lastTabGroupStructureKey = null;
app._tabCollapseStorageFailed = false;
app._inlineRenameActive = false;
app.tabAlerts = new Map();
app.terminalLoadStates = new Map();
app.minimizedSubagents = new Map();
app.hasTabDetachOverride = () => false;
app.renderSubagentTabBadge = () => '';
app.cancelHideSubagentDropdown = () => {};
app.updateTabOverflowMode = () => {};
app.updateConnectionLines = vi.fn();
app._applyTabEntrances = () => {};
app._scrollActiveTabIntoView = () => {};
app._refreshMobileOverviewIfVisible = () => {};
app._refreshHomeSessionsIfVisible = () => {};
app.applySidebarFilter = () => {};
return app;
}
const rowIds = () =>
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
(tab) => tab.dataset.webviewId || tab.dataset.id
);
const badges = () =>
[...document.querySelectorAll<HTMLElement>('#sessionTabs .session-tab')].map(
(tab) => tab.querySelector('.tab-number')?.textContent ?? null
);
beforeEach(() => {
delete window.__codemanUser;
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
document.body.innerHTML = '';
localStorage.removeItem('codeman:tab-groups-collapsed');
});
afterEach(() => {
if (localStorageDescriptor) Object.defineProperty(window, 'localStorage', localStorageDescriptor);
else delete window.localStorage;
});
describe('flat rail is unchanged without groups', () => {
it('renders identical markup for no layout, a failed read and a layout with zero groups', () => {
const none = makeApp({ tabLayout: null });
none._fullRenderSessionTabs();
const flat = document.getElementById('sessionTabs')!.innerHTML;
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
const empty = makeApp({ tabLayout: { version: 3, groups: [], ungrouped: [{ kind: 'session', id: 's3' }] } });
empty._fullRenderSessionTabs();
expect(document.getElementById('sessionTabs')!.innerHTML).toBe(flat);
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
expect(document.getElementById('sessionTabs')!.classList.contains('session-tabs--grouped')).toBe(false);
});
it('keeps the horizontal strip flat even when the owner has groups', () => {
const app = makeApp();
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
app._fullRenderSessionTabs();
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
});
});
describe('grouped vertical rail', () => {
it('draws escaped sections in layout order while badges keep their Alt+N slot', () => {
const app = makeApp();
app._fullRenderSessionTabs();
expect([...document.querySelectorAll('.tab-layout-group-name')].map((el) => el.textContent)).toEqual([
'<Core & Ops>',
'Ungrouped',
]);
expect(document.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
// sessionOrder is s1,s2,s3 and the web tab follows every session: the badge
// names the Alt+N key, never the row position.
expect(badges()).toEqual(['2', '4', '1', '3']);
expect(document.querySelectorAll('.session-tab.active')).toHaveLength(1);
});
it('withholds drag-reorder in the grouped rail only', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const draggable = () =>
[...document.querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.getAttribute('draggable'));
expect(draggable()).toEqual(['false', 'false', 'false']);
const flat = makeApp({ tabLayout: null });
flat._fullRenderSessionTabs();
expect(draggable()).toEqual(['true', 'true', 'true']);
});
it('collapses from the header button, persists per device, and keeps the active row', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const button = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
// The inline onclick calls the GLOBAL app with the button's own dataset.
expect(button.getAttribute('onclick')).toBe('app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)');
app.toggleTabGroupCollapsed(button.dataset.tabGroupHeader);
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
const header = document.querySelector('[data-tab-group-header="group-x"]')!;
expect(header.getAttribute('aria-expanded')).toBe('false');
expect(header.querySelector('.tab-layout-group-count')?.textContent).toBe('3');
expect(rowIds()).toEqual(['s2', 's3']);
expect(app._hiddenTabGroupByRef.get('session:s1')).toBe('group-x');
app.toggleTabGroupCollapsed('group-x');
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('[]');
});
it('reveals a hidden session when it is selected, without expanding its group', () => {
const app = makeApp();
app.collapsedTabGroupIds = new Set(['group-x']);
app._fullRenderSessionTabs();
expect(rowIds()).toEqual(['s2', 's3']);
app.activeSessionId = 's1';
app._updateActiveTabImmediate('s1');
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
expect(rowIds()).toEqual(['s1', 's3']);
expect(badges()).toEqual(['1', '3']);
expect(document.querySelector('.session-tab.active')?.getAttribute('data-id')).toBe('s1');
});
it('patches rows in place while the structure holds, and rebuilds when it changes', () => {
const app = makeApp();
app.collapsedTabGroupIds = new Set(['group-x']);
app._fullRenderSessionTabs();
const full = vi.spyOn(app, '_fullRenderSessionTabs');
app.sessions.get('s3').status = 'busy';
app._renderSessionTabsImmediate();
expect(full).not.toHaveBeenCalled();
expect(document.querySelector('[data-id="s3"] .tab-status')?.className).toBe('tab-status busy');
// Same rows on screen, different grouping: s3 moves into group-x. The id
// sets still match, so only the structure key can catch this.
app.collapsedTabGroupIds = new Set();
app._fullRenderSessionTabs();
full.mockClear();
app.tabLayout = {
...layout,
version: 9,
groups: [{ ...layout.groups[0], refs: [...layout.groups[0].refs, { kind: 'session', id: 's3' }] }],
ungrouped: [],
};
app._renderSessionTabsImmediate();
expect(full).toHaveBeenCalledTimes(1);
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(1);
expect(rowIds()).toEqual(['s2', 'w1', 's1', 's3']);
});
it('falls back to all-expanded when collapse cannot be stored', () => {
const app = makeApp();
const setItem = vi.spyOn(window.Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota');
});
app.toggleTabGroupCollapsed('group-x', true);
setItem.mockRestore();
expect(app.collapsedTabGroupIds.size).toBe(0);
expect(app._tabCollapseStorageFailed).toBe(true);
expect(document.querySelector('[data-tab-group-header="group-x"]')?.getAttribute('aria-expanded')).toBe('true');
});
it('survives localStorage itself being unavailable', () => {
const app = makeApp({ tabLayout: null });
Object.defineProperty(window, 'localStorage', {
configurable: true,
get: () => {
throw new window.DOMException('denied', 'SecurityError');
},
});
expect(() => app._applyTabLayout(layout)).not.toThrow();
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
expect(app.collapsedTabGroupIds.size).toBe(0);
});
it('keeps collapse working after a malformed stored value, and repairs it', () => {
localStorage.setItem('codeman:tab-groups-collapsed', '{"group-x":true}');
const app = makeApp({ tabLayout: null });
app._applyTabLayout(layout);
expect(app._tabCollapseStorageFailed).toBe(false);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('[]');
expect(app.toggleTabGroupCollapsed('group-x', true)).toBe(true);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
expect(document.querySelector('[data-tab-group-header="group-x"]')?.getAttribute('aria-expanded')).toBe('false');
});
it('lights a collapsed header with the most urgent alert it hides, on both render paths', () => {
const app = makeApp();
app.tabAlerts.set('s1', 'idle');
app.collapsedTabGroupIds = new Set(['group-x']);
app._fullRenderSessionTabs();
const header = () => document.querySelector('[data-tab-group-header="group-x"]')!;
const ring = () => ['tab-alert-action', 'tab-alert-idle'].filter((c) => header().classList.contains(c));
// s1 is hidden (s2 is the kept selection): its yellow alert moves to the header.
expect(rowIds()).toEqual(['s2', 's3']);
expect(ring()).toEqual(['tab-alert-idle']);
// A red alert arriving later is patched in place, without a rebuild.
const full = vi.spyOn(app, '_fullRenderSessionTabs');
app.tabAlerts.set('s1', 'action');
app._renderSessionTabsImmediate();
expect(full).not.toHaveBeenCalled();
expect(ring()).toEqual(['tab-alert-action']);
// The kept selection draws its own ring, so its alert never reaches the header.
app.tabAlerts.clear();
app.tabAlerts.set('s2', 'action');
app._renderSessionTabsImmediate();
expect(ring()).toEqual([]);
expect(document.querySelector('[data-id="s2"]')!.classList.contains('tab-alert-action')).toBe(true);
// Expanded, every row shows its own alert and the header shows none.
app.tabAlerts.set('s1', 'action');
app.toggleTabGroupCollapsed('group-x', false);
expect(ring()).toEqual([]);
});
it("lets Ctrl+Shift moves swap only within the active session's own section", () => {
const app = makeApp();
app.saveSessionOrder = vi.fn();
app._fullRenderSessionTabs();
// s2's neighbours in sessionOrder (s1,s2,s3): s3 is Ungrouped, s1 shares group-x.
app.moveActiveTabRight();
expect(app.sessionOrder).toEqual(['s1', 's2', 's3']);
expect(app.saveSessionOrder).not.toHaveBeenCalled();
app.moveActiveTabLeft();
expect(app.sessionOrder).toEqual(['s2', 's1', 's3']);
expect(app.saveSessionOrder).toHaveBeenCalledTimes(1);
// A row a collapse hides is still in its section.
app.sessionOrder = ['s1', 's2', 's3'];
app.collapsedTabGroupIds = new Set(['group-x']);
app._fullRenderSessionTabs();
expect(rowIds()).not.toContain('s1');
app.moveActiveTabLeft();
expect(app.sessionOrder).toEqual(['s2', 's1', 's3']);
// The flat rail (and the strip) keep moving across every neighbour.
const flat = makeApp({ tabLayout: null });
flat.saveSessionOrder = vi.fn();
flat._fullRenderSessionTabs();
flat.moveActiveTabRight();
expect(flat.sessionOrder).toEqual(['s1', 's3', 's2']);
});
});
describe('layout adoption', () => {
it('garbage-collects stale collapse ids and renders on adoption; a failed read renders flat', () => {
localStorage.setItem('codeman:tab-groups-collapsed', '["gone","group-x"]');
const app = makeApp({ tabLayout: null });
app._applyTabLayout(layout);
expect([...app.collapsedTabGroupIds]).toEqual(['group-x']);
expect(localStorage.getItem('codeman:tab-groups-collapsed')).toBe('["group-x"]');
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
app._applyTabLayout(null);
expect(app.tabLayout).toBeNull();
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
expect(rowIds()).toEqual(['s1', 's2', 's3', 'w1']);
});
it('rebuilds the strip on adoption only when what the rail draws changed', () => {
const flat = makeApp({ tabLayout: null });
flat._fullRenderSessionTabs();
const flatRender = vi.spyOn(flat, '_fullRenderSessionTabs');
// The flat rail (every owner without groups) never rebuilds for a layout read.
flat._applyTabLayout({ version: 3, groups: [], ungrouped: [{ kind: 'session', id: 's3' }] });
flat._applyTabLayout({ version: 4, groups: [], ungrouped: [] });
flat._applyTabLayout(null);
expect(flatRender).not.toHaveBeenCalled();
const app = makeApp({ tabLayout: null });
app._fullRenderSessionTabs();
const render = vi.spyOn(app, '_fullRenderSessionTabs');
app._applyTabLayout(layout);
expect(render).toHaveBeenCalledTimes(1);
// A version bump that moves nothing (the broadcast after any session event).
app._applyTabLayout({ ...layout, version: 9 });
expect(render).toHaveBeenCalledTimes(1);
// A rename does change the rail.
app._applyTabLayout({ ...layout, version: 10, groups: [{ ...layout.groups[0], name: 'Ops' }] });
expect(render).toHaveBeenCalledTimes(2);
expect(document.querySelector('.tab-layout-group-name')?.textContent).toBe('Ops');
// Grouped to flat (a failed read) rebuilds once.
app._applyTabLayout(null);
expect(render).toHaveBeenCalledTimes(3);
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(0);
});
it('backs off failed layout reads and stops, without rebuilding a flat rail', async () => {
const app = makeApp({ tabLayout: null });
app._fullRenderSessionTabs();
app._tabLayoutCoordinator = null;
app._apiJson = vi.fn(async () => {
throw new Error('offline');
});
const render = vi.spyOn(app, '_fullRenderSessionTabs');
const realSetTimeout = window.setTimeout;
const delays: number[] = [];
let queued: (() => Promise<boolean>) | null = null;
window.setTimeout = (fn: () => Promise<boolean>, ms: number) => {
delays.push(ms);
queued = fn;
return delays.length;
};
try {
await app._loadTabLayout();
// Bounded: an uncapped retry must fail the assertion below, not hang.
for (let drained = 0; queued && drained < 10; drained++) {
const next: () => Promise<boolean> = queued;
queued = null;
await next();
}
} finally {
window.setTimeout = realSetTimeout;
}
expect(delays).toEqual([5000, 10000, 20000, 40000]);
expect(app._apiJson).toHaveBeenCalledTimes(5);
expect(render).not.toHaveBeenCalled();
app._tabLayoutCoordinator.dispose();
});
it('ignores an older layout than the one it already shows', () => {
const app = makeApp();
app.tabLayout = { ...layout, version: 9 };
app._applyTabLayout({ ...layout, version: 8, groups: [] });
expect(app.tabLayout.version).toBe(9);
});
it('re-reads on tab:layoutChanged only for its own owner and a newer version', () => {
const app = makeApp();
app._loadTabLayout = vi.fn();
app.tabLayout = { version: 8, groups: [], ungrouped: [] };
app._onTabLayoutChanged({ owner: '@single', version: 8 });
expect(app._loadTabLayout).not.toHaveBeenCalled();
app._onTabLayoutChanged({ owner: '@single', version: 9 });
expect(app._loadTabLayout).toHaveBeenCalledTimes(1);
window.__codemanUser = { username: 'alice', multiUser: true };
app._onTabLayoutChanged({ owner: 'bob', version: 20 });
expect(app._loadTabLayout).toHaveBeenCalledTimes(1);
app._onTabLayoutChanged({ owner: 'alice', version: 20 });
expect(app._loadTabLayout).toHaveBeenCalledTimes(2);
});
it('loads through GET /api/tab-layout and adopts the envelope payload', async () => {
const app = makeApp({ tabLayout: null });
app._tabLayoutCoordinator = null;
app._apiJson = vi.fn(async () => ({ layout }));
await app._loadTabLayout();
expect(app._apiJson).toHaveBeenCalledWith('/api/tab-layout');
expect(app.tabLayout.version).toBe(8);
expect(document.querySelectorAll('.tab-layout-group')).toHaveLength(2);
app._tabLayoutCoordinator.dispose();
});
});
describe('lineage in the grouped rail', () => {
function rect(top: number) {
return () => ({ left: 32, top, right: 292, bottom: top + 36, width: 260, height: 36, x: 32, y: top }) as DOMRect;
}
it('anchors a collapse-hidden endpoint to its group header', () => {
const app = makeApp();
app.activeSessionId = 's3';
app.sessions.get('s3').status = 'working';
app.sessions.get('s3').parentSessionId = 's1';
app.collapsedTabGroupIds = new Set(['group-x']);
app._lineageLinesEnabled = () => true;
app._fullRenderSessionTabs();
expect(rowIds()).toEqual(['s3']);
document.getElementById('sessionTabs')!.getBoundingClientRect = () =>
({ left: 0, top: 0, right: 320, bottom: 320, width: 320, height: 320 }) as DOMRect;
document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!.getBoundingClientRect = rect(20);
document.querySelector<HTMLElement>('[data-id="s3"]')!.getBoundingClientRect = rect(180);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
app._appendLineageConnectionLines(svg, new Map());
const line = svg.querySelector('.lineage-line');
expect(line?.classList.contains('lineage-line--proxied')).toBe(true);
expect(line?.getAttribute('data-parent-tab')).toBe('s1');
expect(svg.querySelector('.lineage-line-dot')?.classList.contains('lineage-line-dot--proxied')).toBe(true);
expect(app._lineageEdgeCount).toBe(1);
});
it('draws nothing when both endpoints hide behind the same header', () => {
const app = makeApp();
app.activeSessionId = 's3';
app.sessions.get('s1').parentSessionId = 's2';
app.collapsedTabGroupIds = new Set(['group-x']);
app._lineageLinesEnabled = () => true;
app._fullRenderSessionTabs();
document.getElementById('sessionTabs')!.getBoundingClientRect = rect(0);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
app._appendLineageConnectionLines(svg, new Map());
expect(svg.querySelectorAll('.lineage-line')).toHaveLength(0);
expect(app._lineageEdgeCount).toBe(0);
});
});
describe('grouped rail tree semantics', () => {
const tabs = () => document.getElementById('sessionTabs')!;
const press = (key: string, init: Record<string, unknown> = {}) =>
(document.activeElement as HTMLElement).dispatchEvent(
new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
);
const focused = () => {
const el = document.activeElement as HTMLElement;
return el.dataset.tabGroupHeader ? `group:${el.dataset.tabGroupHeader}` : el.dataset.webviewId || el.dataset.id;
};
const row = (id: string) => document.querySelector<HTMLElement>(`[data-id="${id}"], [data-webview-id="${id}"]`)!;
function makeTreeApp(options: { tabLayout?: unknown } = {}) {
const app = makeApp(options);
// The container as index.html ships it.
tabs().setAttribute('role', 'tablist');
tabs().setAttribute('aria-label', 'Session tabs');
app.selectSession = vi.fn();
app.openWebview = vi.fn();
app.openTabRailActionMenu = vi.fn();
app.showWebviewModal = vi.fn();
return app;
}
it('is a tree only while grouped, and the flat list returns byte-identical as a tablist', () => {
const app = makeTreeApp({ tabLayout: null });
app._fullRenderSessionTabs();
const flat = tabs().innerHTML;
expect(tabs().querySelectorAll('[role="tree"], [role="treeitem"], [role="group"]')).toHaveLength(0);
expect(tabs().querySelectorAll('.session-tab[role="tab"]')).toHaveLength(4);
app._applyTabLayout(layout);
expect(tabs().getAttribute('role')).toBe('tree');
expect(tabs().getAttribute('aria-label')).toBe('Sessions');
expect(tabs().querySelectorAll('[role="tab"]')).toHaveLength(0);
expect(tabs().querySelectorAll('.session-tab[role="treeitem"]')).toHaveLength(4);
app._applyTabLayout(null);
expect(tabs().getAttribute('role')).toBe('tablist');
expect(tabs().getAttribute('aria-label')).toBe('Session tabs');
expect(tabs().innerHTML).toBe(flat);
// The horizontal strip never becomes a tree, groups or not.
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
app._applyTabLayout(layout);
expect(tabs().getAttribute('role')).toBe('tablist');
expect(tabs().querySelectorAll('[role="treeitem"]')).toHaveLength(0);
});
it('nests group rows under their owning header with levels and positions', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
const header = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
const group = document.getElementById(header.getAttribute('aria-owns')!)!;
expect(group.getAttribute('role')).toBe('group');
expect(
[...group.querySelectorAll<HTMLElement>('[role="treeitem"]')].map((el) => el.dataset.webviewId || el.dataset.id)
).toEqual(['s2', 'w1', 's1']);
const aria = (el: HTMLElement) => ['aria-level', 'aria-posinset', 'aria-setsize'].map((a) => el.getAttribute(a));
// Level 1: the group header and the ungrouped row.
expect(aria(header)).toEqual(['1', '1', '2']);
expect(aria(row('s3'))).toEqual(['1', '2', '2']);
expect(aria(row('s2'))).toEqual(['2', '1', '3']);
expect(aria(row('w1'))).toEqual(['2', '2', '3']);
expect(aria(row('s1'))).toEqual(['2', '3', '3']);
// aria-selected follows the active row, exactly once.
expect([...tabs().querySelectorAll('[aria-selected="true"]')].map((el) => (el as HTMLElement).dataset.id)).toEqual([
's2',
]);
});
it('has exactly one tab stop, on the selected row, and no tabbable control inside rows', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
const stops = [...tabs().querySelectorAll<HTMLElement>('[tabindex="0"]')];
expect(stops).toEqual([row('s2')]);
const controls = [...tabs().querySelectorAll<HTMLElement>('.session-tab button, .session-tab [tabindex]')];
expect(controls.length).toBeGreaterThan(0);
expect(controls.every((el) => el.tabIndex === -1)).toBe(true);
// Collapse keeps a single stop (the selection stays visible as a level-1 item).
app.toggleTabGroupCollapsed('group-x', true);
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
expect(row('s2').getAttribute('aria-level')).toBe('1');
expect(document.querySelector('[data-tab-group-header="group-x"]')!.hasAttribute('aria-owns')).toBe(false);
});
it('walks Up/Down/Home/End, collapses and enters groups with Left/Right, and activates with Enter/Space', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
row('s2').focus();
press('ArrowUp');
expect(focused()).toBe('group:group-x');
press('ArrowUp');
expect(focused()).toBe('s3');
press('Home');
expect(focused()).toBe('group:group-x');
press('End');
expect(focused()).toBe('s3');
// An ungrouped row has no parent to climb to.
expect(press('ArrowLeft')).toBe(true);
expect(focused()).toBe('s3');
row('w1').focus();
press('Enter');
expect(app.openWebview).toHaveBeenCalledWith('w1');
row('s1').focus();
press(' ');
expect(app.selectSession).toHaveBeenCalledWith('s1', { forceReload: true });
expect(app.selectSession).toHaveBeenCalledTimes(1);
press('ArrowLeft');
expect(focused()).toBe('group:group-x');
press('ArrowLeft');
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
// The header was re-rendered; focus and the tab stop moved to the new node.
expect(focused()).toBe('group:group-x');
expect(document.querySelector('[data-tab-group-header="group-x"]')!.getAttribute('tabindex')).toBe('0');
press('ArrowRight');
expect(app.collapsedTabGroupIds.has('group-x')).toBe(false);
expect(focused()).toBe('group:group-x');
press('ArrowRight');
expect(focused()).toBe('s2');
press('Home');
press('Enter');
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
// The header was rebuilt by the toggle; focus followed it, never to <body>.
expect(focused()).toBe('group:group-x');
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
});
it('leaves keys pressed on a control inside a row to that control', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
// The overflow button gets focus from a click, or back from its action menu.
const more = row('s2').querySelector<HTMLElement>('.tab-more')!;
more.focus();
expect(press('Enter')).toBe(true);
expect(press(' ')).toBe(true);
expect(press('ArrowDown')).toBe(true);
expect(press('ArrowLeft')).toBe(true);
expect(app.selectSession).not.toHaveBeenCalled();
expect(app.collapsedTabGroupIds.size).toBe(0);
expect(document.activeElement).toBe(more);
});
it('treats a group with no open rows as a leaf that Left/Right cannot open or close', () => {
const app = makeTreeApp({
tabLayout: { ...layout, groups: [...layout.groups, { id: 'empty', name: 'Empty', refs: [] }] },
});
app._fullRenderSessionTabs();
const header = document.querySelector<HTMLElement>('[data-tab-group-header="empty"]')!;
expect(header.hasAttribute('aria-expanded')).toBe(false);
expect(header.hasAttribute('aria-owns')).toBe(false);
header.focus();
press('ArrowRight');
press('ArrowLeft');
expect(app.collapsedTabGroupIds.size).toBe(0);
expect(focused()).toBe('group:empty');
});
it('keeps tree positions in painted order when the sorted rail re-sorts rows in place', () => {
const app = makeTreeApp();
document.documentElement.dataset.tabRailSort = 'activity';
let order = new Map([
['s2', 0],
['s1', 1],
['s3', 2],
]);
app._tabRailSortOrder = () => order;
app._fullRenderSessionTabs();
row('w1').style.order = '9999'; // styles.css pins web tabs last; JSDOM loads no stylesheet
app._applyTabTreePositions(tabs());
const pos = (id: string) => row(id).getAttribute('aria-posinset');
expect([pos('s2'), pos('s1'), pos('w1')]).toEqual(['1', '2', '3']);
// s1 goes working: the incremental pass moves its card up with `order` only.
const full = vi.spyOn(app, '_fullRenderSessionTabs');
order = new Map([
['s1', 0],
['s2', 1],
['s3', 2],
]);
app._renderSessionTabsImmediate();
expect(full).not.toHaveBeenCalled();
expect(row('s1').style.order).toBe('0');
expect([pos('s1'), pos('s2'), pos('w1')]).toEqual(['1', '2', '3']);
expect(row('s1').getAttribute('aria-setsize')).toBe('3');
});
it('exempts tree items from the touch keyboard dismissal, like every other control', () => {
// terminal-ui.js publishes the selector on window.CodemanTerminalInput; its
// top level only needs a window and a CodemanApp prototype to assign to.
const context = vm.createContext({ window: {}, CodemanApp: function CodemanApp() {} });
vm.runInContext(readFileSync(join(PUBLIC, 'terminal-ui.js'), 'utf8'), context);
const selector = (context.window as any).CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR as string;
const app = makeTreeApp();
app._fullRenderSessionTabs();
const header = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
// The roving stop sits on s2: the header and the other rows are tabindex=-1.
expect(header.tabIndex).toBe(-1);
expect(row('s1').tabIndex).toBe(-1);
for (const target of [header, header.querySelector('.tab-layout-group-name')!, row('s1'), row('w1')]) {
expect(target.closest(selector), (target as HTMLElement).className).not.toBeNull();
}
// Inert rail chrome still dismisses.
expect(document.querySelector('.tab-layout-ungrouped-header')!.closest(selector)).toBeNull();
});
it('opens row actions from the keyboard, since its controls left the tab order', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
row('s2').focus();
press('F10', { shiftKey: true });
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(expect.objectContaining({ currentTarget: row('s2') }), 's2');
row('w1').focus();
press('ContextMenu');
expect(app.showWebviewModal).toHaveBeenCalledWith('w1');
press('F10');
expect(app.showWebviewModal).toHaveBeenCalledTimes(1);
});
it('restores focus by identity across a background rebuild and follows pointer focus', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
row('w1').focus();
app._fullRenderSessionTabs();
expect(focused()).toBe('w1');
expect([...tabs().querySelectorAll('[tabindex="0"]')]).toEqual([row('w1')]);
// Focus arriving by pointer (or any other route) takes the tab stop with it.
row('s3').focus();
expect([...tabs().querySelectorAll('[tabindex="0"]')]).toEqual([row('s3')]);
// A rebuild never pulls focus into the rail when it was elsewhere.
(document.activeElement as HTMLElement).blur();
app._fullRenderSessionTabs();
expect(document.activeElement).toBe(document.body);
});
it('keeps aria-selected in step when the selection changes without a rebuild', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
const full = vi.spyOn(app, '_fullRenderSessionTabs');
app.activeSessionId = 's1';
app._updateActiveTabImmediate('s1');
expect(full).not.toHaveBeenCalled();
expect([...tabs().querySelectorAll('[aria-selected="true"]')].map((el) => (el as HTMLElement).dataset.id)).toEqual([
's1',
]);
});
it('walks a sorted rail in painted order: per group in the tree, across the list when flat', () => {
const app = makeTreeApp();
app._fullRenderSessionTabs();
document.documentElement.dataset.tabRailSort = 'activity';
row('s1').style.order = '0';
row('s2').style.order = '1';
row('w1').style.order = '9999';
row('s3').style.order = '2';
document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!.focus();
const walk = () =>
Array.from({ length: 4 }, () => {
press('ArrowDown');
return focused();
});
expect(walk()).toEqual(['s1', 's2', 'w1', 's3']);
const flat = makeTreeApp({ tabLayout: null });
flat._fullRenderSessionTabs();
document.documentElement.dataset.tabRailSort = 'activity';
row('s1').style.order = '2';
row('s2').style.order = '0';
row('s3').style.order = '1';
row('w1').style.order = '9999'; // styles.css pins web tabs last; JSDOM loads no stylesheet
row('s2').focus();
expect(
Array.from({ length: 3 }, () => {
press('ArrowDown');
return focused();
})
).toEqual(['s3', 's1', 'w1']);
});
});
describe('flat list keyboard activation', () => {
it('opens a web tab with Enter/Space instead of selecting an undefined session', () => {
const app = makeApp({ tabLayout: null });
app.selectSession = vi.fn();
app.openWebview = vi.fn();
app._fullRenderSessionTabs();
const web = document.querySelector<HTMLElement>('[data-webview-id="w1"]')!;
web.focus();
web.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
web.dispatchEvent(new window.KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
expect(app.openWebview).toHaveBeenCalledTimes(2);
expect(app.selectSession).not.toHaveBeenCalled();
const s2 = document.querySelector<HTMLElement>('[data-id="s2"]')!;
s2.focus();
s2.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(app.selectSession).toHaveBeenCalledWith('s2', { forceReload: true });
});
});
+11 -10
View File
@@ -205,10 +205,11 @@ describe('vertical tab rail row order', () => {
it('keeps the Alt+N badge on the tab index while the cards are sorted', () => {
// `_tabIdx` counts the loop over sessionOrder, and only `style="order:…"`
// moves the card — so the badge names a shortcut, not a row position.
expect(appJs).toContain(
'const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));'
);
expect(appJs).toContain('` style="order:${railSortOrder.get(id)}"`');
// `listOrder` is the rail sort's map, or the state grouping's when that is
// on (test/tab-triage.test.ts); either way it only sets the inline order.
expect(appJs).toContain('const liveIds = tabOrder.filter((id) => this.sessions.has(id));');
expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
expect(appJs).toContain('` style="order:${listOrder.get(id)}"`');
expect(appJs).toMatch(/_tabIdx < 9 \? '<span class="tab-number">' \+ \(_tabIdx \+ 1\)/);
// The loop itself still walks the user's order, which is what makes the
// badge, drag-and-drop and the arrow-key walk agree with each other.
@@ -219,12 +220,11 @@ describe('vertical tab rail row order', () => {
// A session going working→idle never adds or removes a tab, so the full
// rebuild is not reached — and a rebuild here would restart every card's
// animation on every SSE tick anyway.
expect(appJs).toContain(
'const railSortOrder = this._tabRailSortOrder(this.sessionOrder.filter((sid) => this.sessions.has(sid)));'
);
expect(appJs).toContain('const liveIds = this.sessionOrder.filter((sid) => this.sessions.has(sid));');
expect(appJs).toContain('const railSortOrder = this._tabRailSortOrder(liveIds);');
expect(appJs).toContain('if (tab.style.order !== railOrder) tab.style.order = railOrder;');
// An empty string is what clears the property when the rail stops sorting.
expect(appJs).toContain("const railOrder = railSortOrder?.has(id) ? String(railSortOrder.get(id)) : '';");
expect(appJs).toContain("const railOrder = listOrder?.has(id) ? String(listOrder.get(id)) : '';");
});
it('drops the drag affordance while sorting, so a card cannot snap back', () => {
@@ -280,9 +280,10 @@ describe('vertical tab rail sort setting', () => {
expect(html).toContain("document.documentElement.dataset.tabRailSort='activity';");
expect(settingsUi).toContain('root.dataset.tabRailSort = sort;');
// A sort flip leaves orientation on 'vertical' both times, so it has to
// count as a change of its own or nothing re-renders.
// count as a change of its own or nothing re-renders. The same holds for a
// grouping flip (tabGrouping, test/tab-triage.test.ts).
expect(settingsUi).toContain(
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;'
'const changed = orientationChanged || previousDetail !== detail || previousSort !== sort || previousGrouping !== grouping;'
);
});
});
+390
View File
@@ -0,0 +1,390 @@
/**
* @fileoverview Tab grouping by state (`tabGrouping: 'state'`, Discussion #426
* option C): the pure layout in constants.js and the render paths in app.js.
*
* What is pinned, and why it matters:
* - The four groups and their order (needs you, waiting, working, idle) and
* which home-screen state lands in which, including the two folds that are
* easy to get wrong: a failed session joins "needs you", and an agent that
* exited inside a live pane (#446) is idle even while its status says busy.
* - It is applied as flex `order` only. The DOM stays in tab order, so Alt+N
* badges, drag and the keyboard walk keep reading the list they always read.
* - A state change is an INCREMENTAL pass: the tab element survives, only its
* `order` and the heading counts move, and a group that empties loses its
* heading.
* - `tabGrouping: 'none'` leaves no trace (no headings, no inline order, no
* class), and named groups in the vertical rail win over it.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
* `document` below is that window's.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
type Row = { id: string; state: string; exited?: boolean; pos?: number };
type Layout = {
order: Map<string, number>;
webOrder: Map<string, number>;
groups: Array<{ key: string; label: string; count: number; headOrder: number; breakOrder: number }>;
};
type Triage = {
GROUPS: Array<{ key: string; label: string }>;
STRIDE: number;
groupFor: (state: string, exited: boolean) => string;
layout: (rows: Row[], webviewIds?: string[]) => Layout;
};
function loadTriage(): Triage {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanTabTriage: Triage }).CodemanTabTriage;
}
describe('CodemanTabTriage (pure)', () => {
const triage = loadTriage();
it('has four groups, most urgent first', () => {
expect(triage.GROUPS.map((g) => g.key)).toEqual(['needs', 'waiting', 'working', 'idle']);
expect(triage.GROUPS.map((g) => g.label)).toEqual(['Needs you', 'Waiting', 'Working', 'Idle']);
});
it('folds the six home-screen states into the four groups', () => {
expect(triage.groupFor('needs', false)).toBe('needs');
expect(triage.groupFor('error', false)).toBe('needs');
expect(triage.groupFor('waiting', false)).toBe('waiting');
expect(triage.groupFor('working', false)).toBe('working');
expect(triage.groupFor('idle', false)).toBe('idle');
expect(triage.groupFor('done', false)).toBe('idle');
expect(triage.groupFor('something-new', false)).toBe('idle');
});
it('puts an exited agent with idle, never with working', () => {
expect(triage.groupFor('working', true)).toBe('idle');
expect(triage.groupFor('idle', true)).toBe('idle');
// A human being blocked still outranks the agent having exited.
expect(triage.groupFor('needs', true)).toBe('needs');
expect(triage.groupFor('waiting', true)).toBe('waiting');
});
it('gives each group a band of order values and lists only non-empty groups', () => {
const out = triage.layout([
{ id: 'a', state: 'idle', pos: 0 },
{ id: 'b', state: 'needs', pos: 1 },
{ id: 'c', state: 'idle', pos: 2 },
]);
expect(out.groups.map((g) => [g.key, g.count])).toEqual([
['needs', 1],
['idle', 2],
]);
const needs = out.groups[0];
const idle = out.groups[1];
expect(out.order.get('b')).toBeGreaterThan(needs.headOrder);
expect(out.order.get('b')).toBeLessThan(needs.breakOrder);
// Rows keep their relative position inside a group.
expect(out.order.get('a')).toBeLessThan(out.order.get('c')!);
expect(out.order.get('a')).toBeGreaterThan(idle.headOrder);
// A heading opens its band, the break closes it, and bands never overlap.
expect(needs.breakOrder).toBeLessThan(idle.headOrder);
expect(Math.floor(out.order.get('b')! / triage.STRIDE)).toBe(Math.floor(needs.headOrder / triage.STRIDE));
});
it('ranks rows inside a group by `pos`, stably', () => {
const out = triage.layout([
{ id: 'x', state: 'working', pos: 5 },
{ id: 'y', state: 'working', pos: 1 },
{ id: 'z', state: 'working', pos: 1 },
]);
const sorted = [...out.order.entries()].sort((p, q) => p[1] - q[1]).map(([id]) => id);
expect(sorted).toEqual(['y', 'z', 'x']);
});
it('closes the idle group with the web tabs and counts them', () => {
const out = triage.layout([{ id: 's', state: 'idle', pos: 0 }], ['w1', 'w2']);
expect(out.groups).toHaveLength(1);
expect(out.groups[0]).toMatchObject({ key: 'idle', count: 3 });
expect(out.webOrder.get('w1')).toBeGreaterThan(out.order.get('s')!);
expect(out.webOrder.get('w2')).toBeGreaterThan(out.webOrder.get('w1')!);
expect(out.webOrder.get('w2')).toBeLessThan(out.groups[0].breakOrder);
});
it('shows the idle group for web tabs alone, and nothing for nothing', () => {
expect(triage.layout([], ['w1']).groups.map((g) => g.key)).toEqual(['idle']);
expect(triage.layout([], []).groups).toEqual([]);
expect(triage.layout(undefined as unknown as Row[], undefined).groups).toEqual([]);
});
});
describe('tab grouping in the render paths (app.js)', () => {
let CodemanApp: { prototype: Record<string, any> };
let window: any;
let document: Document;
beforeAll(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
window = dom.window;
document = window.document;
window.setInterval = () => 0;
window.requestAnimationFrame = () => 0;
window.CSS = { escape: (value: string) => value };
window.eval(
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('tab-layout-browser.js') +
'\n' +
read('app.js') +
'\n' +
read('mobile-overview.js') +
'\n' +
read('webview-tabs.js') +
'\n;window.__TriageCodemanApp = CodemanApp;'
);
CodemanApp = window.__TriageCodemanApp;
});
function makeApp(grouping: 'state' | 'none' = 'state') {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
const root = document.documentElement;
root.setAttribute('data-tab-orientation', 'horizontal');
root.dataset.tabRailSort = 'activity';
root.dataset.tabGrouping = grouping;
document.body.innerHTML = '<div class="session-tabs-host"><div id="sessionTabs" class="session-tabs"></div></div>';
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([
['s1', { id: 's1', name: 'w1-alpha', status: 'idle' }],
['s2', { id: 's2', name: 'w2-beta', status: 'busy' }],
['s3', { id: 's3', name: 'w3-gamma', status: 'idle' }],
['s4', { id: 's4', name: 'w4-delta', status: 'busy' }],
]);
app.sessionOrder = ['s1', 's2', 's3', 's4'];
app.pendingHooks = new Map([
['s3', new Set(['permission_prompt'])],
['s1', new Set(['idle_prompt'])],
]);
app.webviews = new Map([['w1', { id: 'w1', name: 'Dashboard', url: 'https://example.test' }]]);
app.webviewOrder = ['w1'];
app.activeSessionId = 's2';
app.activeWebviewId = null;
app.tabLayout = null;
app.collapsedTabGroupIds = new Set();
app._hiddenTabGroupByRef = new Map();
app._lastTabGroupStructureKey = null;
app._inlineRenameActive = false;
app.tabAlerts = new Map();
app.terminalLoadStates = new Map();
app.minimizedSubagents = new Map();
app.hasTabDetachOverride = () => false;
app.renderSubagentTabBadge = () => '';
app.cancelHideSubagentDropdown = () => {};
app.updateTabOverflowMode = () => {};
app.updateConnectionLines = vi.fn();
app._applyTabEntrances = () => {};
app._scrollActiveTabIntoView = () => {};
app._refreshMobileOverviewIfVisible = () => {};
app._refreshHomeSessionsIfVisible = () => {};
app.applySidebarFilter = () => {};
return app;
}
const container = () => document.getElementById('sessionTabs')!;
const tab = (id: string) => container().querySelector<HTMLElement>(`.session-tab[data-id="${id}"]`)!;
const orderOf = (el: HTMLElement) => Number(el.style.order);
/** What the eye sees: every ordered child, by `order`, as `head:<key>` / `<id>`. */
const visual = () =>
[...container().children]
.filter((el) => !(el as HTMLElement).classList.contains('tab-triage-break'))
.sort((a, b) => orderOf(a as HTMLElement) - orderOf(b as HTMLElement))
.map((el) => {
const h = el as HTMLElement;
if (h.classList.contains('tab-triage-head')) return `head:${h.dataset.triageGroup}`;
return h.dataset.webviewId ? `web:${h.dataset.webviewId}` : h.dataset.id!;
});
const heads = () =>
[...container().querySelectorAll<HTMLElement>(':scope > .tab-triage-head')]
.sort((a, b) => orderOf(a) - orderOf(b))
.map((h) => `${h.querySelector('.tab-triage-label')!.textContent}:${h.querySelector('.tab-triage-count')!.textContent}`);
beforeEach(() => {
document.body.innerHTML = '';
});
it('draws a heading per non-empty state and places every tab in its group', () => {
makeApp()._fullRenderSessionTabs();
expect(container().classList.contains('tabs-triage')).toBe(true);
expect(heads()).toEqual(['Needs you:1', 'Waiting:1', 'Working:2', 'Idle:1']);
expect(visual()).toEqual([
'head:needs',
's3',
'head:waiting',
's1',
'head:working',
's2',
's4',
'head:idle',
'web:w1',
]);
// One row break per heading; headings and breaks stay out of the tablist.
expect(container().querySelectorAll(':scope > .tab-triage-break')).toHaveLength(4);
for (const el of container().querySelectorAll(':scope > .tab-triage-head, :scope > .tab-triage-break')) {
expect(el.getAttribute('aria-hidden')).toBe('true');
}
});
it('keeps the DOM, and with it the Alt+N badges, in tab order', () => {
makeApp()._fullRenderSessionTabs();
const domOrder = [...container().querySelectorAll<HTMLElement>('.session-tab[data-id]')].map((t) => t.dataset.id);
expect(domOrder).toEqual(['s1', 's2', 's3', 's4']);
expect(tab('s3').querySelector('.tab-number')?.textContent).toBe('3');
});
it('moves a tab between groups on an incremental pass, without rebuilding it', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const s2 = tab('s2');
const fullRender = vi.spyOn(app, '_fullRenderSessionTabs');
// s2 finishes its turn; the dialog on s3 is answered.
app.sessions.get('s2').status = 'idle';
app.pendingHooks.delete('s3');
app._renderSessionTabsImmediate();
expect(fullRender).not.toHaveBeenCalled();
expect(tab('s2')).toBe(s2);
expect(heads()).toEqual(['Waiting:1', 'Working:1', 'Idle:3']);
expect(container().querySelector('.tab-triage-head[data-triage-group="needs"]')).toBeNull();
expect(container().querySelector('.tab-triage-break[data-triage-group="needs"]')).toBeNull();
expect(visual()).toEqual(['head:waiting', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
});
it('reconciles headings in place: an unchanged pass rewrites nothing', () => {
const app = makeApp();
app._fullRenderSessionTabs();
app._renderSessionTabsImmediate();
const head = container().querySelector('.tab-triage-head[data-triage-group="working"]');
app._renderSessionTabsImmediate();
expect(container().querySelector('.tab-triage-head[data-triage-group="working"]')).toBe(head);
expect(container().querySelectorAll(':scope > .tab-triage-head')).toHaveLength(4);
});
it('files an agent that exited in a live pane under idle, a failed session under needs you', () => {
const app = makeApp();
app.pendingHooks = new Map();
app.sessions.get('s2').paneExit = { at: 1, status: 0 };
app.sessions.get('s1').status = 'error';
app._fullRenderSessionTabs();
expect(visual()).toEqual(['head:needs', 's1', 'head:working', 's4', 'head:idle', 's2', 's3', 'web:w1']);
});
it("leaves no trace with tabGrouping 'none'", () => {
makeApp('none')._fullRenderSessionTabs();
expect(container().classList.contains('tabs-triage')).toBe(false);
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
expect(container().innerHTML).not.toContain('order:');
expect(container().querySelector<HTMLElement>('.session-tab[data-webview-id]')!.style.order).toBe('');
});
it('switching off removes the headings and every inline order it wrote', () => {
const app = makeApp();
app._fullRenderSessionTabs();
document.documentElement.dataset.tabGrouping = 'none';
app._renderSessionTabsImmediate();
expect(container().querySelector('.tab-triage-head, .tab-triage-break')).toBeNull();
for (const el of container().querySelectorAll<HTMLElement>('.session-tab')) expect(el.style.order).toBe('');
expect(container().classList.contains('tabs-triage')).toBe(false);
});
it('ranks rows inside each section by the activity sort on a sorted rail', () => {
const app = makeApp();
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
app.pendingHooks = new Map();
// Both working; s4 has been running longer, so the activity sort puts it first.
app.sessions.get('s2').lastSubmitAt = 2_000;
app.sessions.get('s4').lastSubmitAt = 1_000;
app._fullRenderSessionTabs();
expect(visual()).toEqual(['head:working', 's4', 's2', 'head:idle', 's1', 's3', 'web:w1']);
});
it('keeps tab order inside each row on the header strip', () => {
const app = makeApp();
app.pendingHooks = new Map();
app.sessions.get('s2').lastSubmitAt = 2_000;
app.sessions.get('s4').lastSubmitAt = 1_000;
app._fullRenderSessionTabs();
expect(visual()).toEqual(['head:working', 's2', 's4', 'head:idle', 's1', 's3', 'web:w1']);
});
it('lets named groups in the vertical rail win', () => {
const app = makeApp();
document.documentElement.setAttribute('data-tab-orientation', 'vertical');
app.tabLayout = {
version: 1,
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's1' }] }],
ungrouped: [],
};
app._fullRenderSessionTabs();
expect(container().classList.contains('session-tabs--grouped')).toBe(true);
expect(container().classList.contains('tabs-triage')).toBe(false);
expect(container().querySelector('.tab-triage-head')).toBeNull();
});
it('refuses a drop into another group and allows one inside the same group', () => {
const app = makeApp();
app._fullRenderSessionTabs();
app.draggedTabId = 's2';
expect(app._isTabDropAcrossTriageGroups(tab('s4'))).toBe(false);
expect(app._isTabDropAcrossTriageGroups(tab('s1'))).toBe(true);
expect(app._isTabDropAcrossTriageGroups(tab('s3'))).toBe(true);
document.documentElement.dataset.tabGrouping = 'none';
app._fullRenderSessionTabs();
expect(app._isTabDropAcrossTriageGroups(tab('s1'))).toBe(false);
});
it('degrades to the flat strip when mobile-overview.js is stale or missing', () => {
const app = makeApp();
app._mobileOverviewState = undefined;
app._fullRenderSessionTabs();
expect(container().querySelector('.tab-triage-head')).toBeNull();
expect(container().classList.contains('tabs-triage')).toBe(false);
});
});
describe('tab grouping wiring (static)', () => {
const html = read('index.html');
const css = read('styles.css');
const mobileCss = read('mobile.css');
it('stamps data-tab-grouping before first paint, defaulting to state', () => {
expect(html).toContain("dataset.tabGrouping=(A.tabGrouping==='none')?'none':'state'");
// The catch branch (localStorage threw) must set it too.
expect(html).toContain("document.documentElement.dataset.tabGrouping='state';");
});
it('offers the setting with "By state" as the default', () => {
expect(html).toMatch(/<select id="appSettingsTabGrouping"[^>]*>\s*<option value="state">By state \(default\)<\/option>\s*<option value="none">/);
});
it('only shows row breaks in a wrapping header strip', () => {
expect(css).toMatch(/\.tab-triage-break \{\s*display: none;/);
expect(css).toContain(
'.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break'
);
});
it('hides the headings on phones, whose strip stays one scrolling row', () => {
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-triage-head \{\s*display: none;/);
});
});

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