Compare commits

...
Author SHA1 Message Date
Codeman maintainer 9cfd8e8989 fix(docker): survive xAI installer's own /usr/local/bin/grok symlink
The agent-image grok step copied /root/.grok/bin/grok onto /usr/local/bin/grok
with cp -L. Newer versions of xAI's install.sh already create
/usr/local/bin/grok as a symlink to that same binary, so the copy failed with
'same file' and the --no-cache rebuild died at the grok layer (2026-08-24).
Stage the copy under a temp name, drop whatever the installer left at the
destination, then move into place - correct against both old and new
installers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 01:12:38 +02:00
Codeman maintainer 8fe393826b chore: version packages 2026-08-24 01:00:51 +02:00
Codeman maintainer 7a340fe7bc fix(tabs): review-driven hardening for the tab-layout foundation and vertical rail
Post-merge follow-ups from the deep review of #334 and #335, so they ship in
the same release as the features.

Tab-layout foundation (#335):
- PUT /api/session-order drops unknown/foreign ids again instead of 400ing
  the whole write, in both the owner and the admin path (single-user requests
  are the synthetic admin, so that path is the one the browser hits). The
  frontend debounces its reorder push and swallows errors, so a session
  deleted inside the debounce window silently cost the user the entire
  reorder - and the endpoint sits on the stable /api/v1 surface, where the
  pre-layout server merged leniently.
- A failed mux restore no longer locks explicit deletions into 500s for the
  process lifetime: runSessionDeletion and webviewDeleted degrade to
  best-effort without layout coordination, while the automated stale sweep
  (runStaleSessionCleanup) stays fail-closed.
- sse-events doc comment: no 'suppressed' hook event exists; hooks stay 8.
- registerSessionWithLayout resolves its owner through ownerLayoutKey()
  instead of a hardcoded '@single'.

Vertical rail (#334) - all rail-awareness gaps in sidebar-only predicates,
unified behind the new _isVerticalTabList() (sidebar OR rail):
- Drag-reorder read the insertion side from clientX in the rail, so
  before/after was effectively arbitrary on vertical rows; the drag-over
  indicators now draw as top/bottom edges there like the sidebar's.
- The active tab is scrolled into view in the rail (Alt+N/palette selection
  used to leave the row below the fold).
- Floating subagent/ultracode windows anchor to the RIGHT of rail tabs, and
  the connector redraw gates (render tail + strip scroll) cover the rail.
- Server-seeded tabOrientation is applied when the async settings load
  resolves, not only at boot, so a fresh device shows the rail immediately.
- The pre-paint script stamps data-tab-orientation and --tab-rail-width
  (sidebar-wins and solo carve-outs included), removing the flash of the
  header strip on every vertical-mode load.
- The session name font defaults to 12px, the sidebar's historical 0.75rem
  size, so installs that never touch the new slider are not restyled.

Also documents the rail in CLAUDE.md (second #sessionTabs host, mover
ordering, the axis-predicate rule) and gives tab-rail-resize.js its
@dependency/@loadorder header. Full gate green (6093 tests); the excluded
browser suite was run by hand - only the known environmental failures
(opencode/codex binaries) remain, identical to pristine master.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 00:59:48 +02:00
Codeman maintainer f3c615b669 fix(files): give the file preview a working detach button
The button next to the file preview's close icon was Copy Content, whose
overlapping-pages glyph reads as a pop-out control - and for a PDF or any
media/binary preview it was completely dead: those branches never fill
filePreviewContent, so the click hit an empty-content guard and did nothing,
with no feedback.

There is now a real detach button that opens the previewed file in a browser
tab (raw route for PDFs/images/media/text, the server-converted PDF preview
for docx/pptx), severs window.opener by hand so a blocked pop-up stays
detectable, closes the overlay on success (which also stops any playing
media), and disarms on close so it can never open a stale file. The copy
button now toasts 'Nothing to copy in this preview' instead of staying
silent.

Verified live with Playwright against an isolated instance: button visible
and armed on a PDF preview, file-raw answers 200, clicking opens the URL and
tears the overlay down, text previews keep a working copy buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 00:59:27 +02:00
Ark0N 82f81d21c4 Merge pull request #333 from Ark0N/feat/grok-mode
feat(grok): add Grok Build (xAI) as a seventh CLI run mode
2026-08-24 00:43:04 +02:00
Codeman maintainer c173ae0264 Merge remote-tracking branch 'origin/master' into worktree-grok-mode
# Conflicts:
#	src/web/public/app.js
2026-08-24 00:32:25 +02:00
Ark0N e9dd55e5fd Merge pull request #334 from aakhter/pr/cod-358-vertical-rail
feat(tabs): add a resizable vertical session rail
2026-08-24 00:14:10 +02:00
Ark0N dd96f252ea Merge pull request #335 from aakhter/pr/cod-359-tab-layout
feat(tabs): add owner-scoped tab layout foundation
2026-08-24 00:12:20 +02:00
Aamer Akhter 74194e4fc0 feat(tabs): COD-359 add owner-scoped tab layouts 2026-08-23 14:46:10 -04:00
Aamer Akhter c45c6c3846 merge upstream master into COD-358 2026-08-23 14:15:07 -04:00
Aamer Akhter 17b141dc25 test(workflows): keep recent-run fixture clock-independent 2026-08-23 14:12:38 -04:00
Codeman maintainer 57f326ab8f docs(grok): fix the mode counts and line refs the seventh mode invalidated
The agent skill's endpoints.md is what other agents read as ground truth, and
three of its facts went stale when grok landed:

- `/api/v1/grok/status` was added to the probe list, but the sentence after it
  still said only Pi's response carries `.data.version`. Grok's carries it for
  the same reason (a squatted binary name), and an agent that trusts the old
  wording has no way to tell a misresolved grok from an absent one.
- the `active-tools` bullet listed grok among the modes it stays empty for, then
  claimed in the same breath that `isExternalCliMode` "lists only those five".
- its three source line refs had all drifted: `isExternalCliMode` is now
  session.ts:174-183 (it was already wrong before this branch), the external-CLI
  early return is session.ts:2261, and TEXT_COMMAND_PATTERN is
  bash-tool-parser.ts:89.

CLAUDE.md and architecture-invariants.md counted modes in their Docker-cases and
Web-tabs paragraphs ("any of the five CLI backends", "never a sixth
SessionMode"). Both numbers were already stale before grok (antigravity and pi
had made it seven) and grok is now in the agent image, so the counts are gone
rather than incremented: the invariant those sentences carry is that Docker and
web tabs are not modes at all, which no number has ever helped state. The two
plan docs keep their original wording, being historical design records.
2026-08-23 19:57:13 +02:00
Codeman maintainer 6b0b6d10ad fix(test): anchor the workflow-run fixture to now instead of a pinned epoch
test/workflow-run-watcher.test.ts pinned its fixture's newest activity at
2026-06-14T20:06:40Z and then asked getRecentRunSummaries(100000) to return
it. That argument is MINUTES, so the window is 69.4 days: the assertion
expired at 2026-08-23T06:46:40Z and the file has failed on every branch
since, on a suite nobody had touched. The last green CI run finished at
06:47:43Z, about a minute inside the boundary, which is why it landed as a
surprise rather than a bisectable regression.

The fixture epochs now hang off a RUN_ANCHOR of Date.now() - 601s with every
offset preserved verbatim, so the parsed durations, the ordering and the
live-vs-done discriminators are all unchanged, and the recency filter is
still the thing under test. It just cannot rot again.
2026-08-23 19:54:15 +02:00
Aamer Akhter c9ea8bbac5 fix(tabs): COD-358 re-query tab after rename cancel 2026-08-23 13:40:42 -04:00
Aamer Akhter 1795a138b3 test(workflows): document clock-independent fixture fix 2026-08-23 12:47:39 -04:00
Aamer Akhter e3a2fb767f feat(tabs): COD-358 add resizable vertical session rail 2026-08-23 12:21:02 -04:00
Codeman maintainer 3f8c8e99d1 feat(grok): add Grok Build (xAI) as a seventh CLI run mode
SessionMode gains 'grok', a first-class backend alongside Claude Code,
shell, OpenCode, Codex, Gemini, Antigravity and Pi: its own PTY, tmux
session, charcoal tab identity ('gk' badge), welcome button, run-mode
entry, cron agentType, Docker and remote-SSH command defaults, and
clone-repo Brain option. Flag surface verified live against grok 1.0.5.

Grok mixes two existing shapes and the wiring follows from that:

- Codex-shaped on permissions: the bypass switch is GrokConfig.alwaysApprove
  (--always-approve, grok's bypassPermissions mode; config-level deny rules
  still apply on top). The Run button sends it true, like runAntigravity(),
  and clampExternalCliBypassForOwner() puts grok in the only-if-sent branch:
  a bare grok spawn is grok's own ask-mode default, which is already safe,
  so only a sent config needs the flag forced off. Cron needs nothing for
  the same reason.
- OpenCode-shaped on rendering: grok is a fullscreen alternate-screen TUI
  with mouse support, so it stays OUT of isAltScreenStripMode() and lands
  on the narrow tmux-attach strip and the 'buffer' local-echo fallthrough
  (unmeasured against an authenticated composer; documented fallback is the
  'off' branch).
- Pi-shaped on resolution: 'grok' has npm squatters (@vibe-kit/grok-cli
  also installs a grok bin), so grok-cli-resolver.ts version-probes every
  candidate (grok --version, killSignal SIGKILL, VITEST-gated) and
  GET /api/grok/status surfaces path AND version; GROK_VERSION_REGEX is
  shared with the dependency registry so doctor and run mode cannot drift.

Env allowlist gains GROK_* plus the XAI_* vendor namespace (XAI_API_KEY is
grok's documented headless auth var), the same narrow-vendor reasoning as
GOOGLE_* for gemini. Resume is id-regexed on purpose: grok's own --resume
also matches session titles, which are arbitrary user strings that must
never reach the bash -c spawn line.

Docker: grok is not on npm, so the agent image installs it in its own step
(xAI's installer has no --dir override; the binary is copied to
/usr/local/bin and root's ~/.grok dropped in the same layer), and
credentials are seeded per-file (auth.json, config.toml, pager.toml; the
dir also holds sessions/, memory/ and the ~160MB binary). Remote SSH routes
through the login-shell wrapper like the other agent CLIs.

Verified end to end on an isolated CODEMAN_INSTANCE with grok 1.0.5
installed: /api/grok/status resolves and reports the probed version,
quick-start spawns a pane whose command line ends in 'grok
--always-approve', the real TUI renders (OAuth device screen on an
unauthenticated box), and grokConfig round-trips through state.json.
Docs: docs/grok-integration.md (user guide) + docs/grok-integration-plan.md
(decisions, verification record, follow-ups).

Tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts, plus
extended clamp/system-routes/render-index-html/run-mode-ui/mobile-overview/
local-echo-gating coverage. npm test (the CI gate) green: 5910 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 08:39:03 +02:00
110 changed files with 8931 additions and 341 deletions
+12
View File
@@ -1,5 +1,17 @@
# aicodeman
## 1.22.0
### Minor Changes
- 3f8c8e9: Add Grok Build (xAI `grok`) as a seventh CLI run mode. SessionMode gains 'grok', with its own resolver (version-probed, since the name has npm squatters; GET /api/grok/status surfaces path + version), GrokConfig (model, alwaysApprove -> --always-approve, resume/continue), GROK*\*/XAI*\* env allowlist entries, the multi-user only-if-sent bypass clamp, Docker (own image step + per-file credential seeding) and remote-SSH command defaults, cron agentType, run-mode/welcome/tab UI with a charcoal identity, and docs (grok-integration.md + plan). Verified end to end against grok 1.0.5 on an isolated instance.
- 74194e4: Add the owner-scoped tab-layout model, persistence, API, lifecycle repair, and synchronized legacy ordering foundation.
- e3a2fb7: Add an optional resizable vertical session rail with responsive layout, complete labels, accessible controls, and stable inline rename.
### Patch Changes
- Fix the file preview's dead pop-out control: a real detach button now opens the previewed file in a browser tab (raw route for PDFs/images/media/text, converted-PDF preview for docx/pptx) and the copy button reports when a preview has no text to copy instead of silently doing nothing. Review-driven hardening for the new tab features: PUT /api/session-order drops unknown ids again instead of rejecting the whole write (a session deleted inside the browser's debounce window could silently lose the user's reorder), a failed mux restore no longer blocks explicit session/webview deletion for the process lifetime (the automated stale sweep stays fail-closed), and the vertical rail gains the axis-awareness the sidebar-only predicates missed: correct drag-reorder insertion, active-tab scroll-into-view, floating windows anchored beside rail tabs, connector redraws on rail scroll, server-seeded orientation applied on first load, a pre-paint stamp so vertical mode no longer flashes through the header strip, and a 12px session-name default matching the sidebar's historical size so untouched installs are not restyled.
## 1.21.0
### Minor Changes
+13 -13
View File
@@ -75,13 +75,13 @@ 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.21.0 (must match `package.json`)
**Version**: 1.22.0 (must match `package.json`)
## Project Overview
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google) and Pi (pi.dev) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'`).
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev) and Grok Build (`grok`, xAI) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'`).
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
@@ -126,10 +126,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` 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`). → [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)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` 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`). → [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
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*`) 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). 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. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*`) 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. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-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`.
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
@@ -173,7 +173,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Config**: `src/config/` — 21 files, no barrel (`index.ts`) exists; import from the specific file.
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver` (CLI path resolution; ⚠ `pi-cli-resolver` additionally version-probes the binary, since `pi` is a generic name), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver` (CLI path resolution; ⚠ `pi-cli-resolver` and `grok-cli-resolver` additionally version-probe the binary, since `pi` is a generic name and `grok` has npm squatters), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
### Data Flow
@@ -202,9 +202,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs inside a durable remote `tmux -L codeman-remote` (session name `codeman-ssh-<id>`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill** on tab close; owned ones propagate `kill-session`. A bounded-backoff watcher auto-reconnects dropped sessions (`remoteAutoReconnect`, default ON). ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**, which `shellescape`s every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through `POST /api/quick-start`, not `POST /api/sessions` (which stat-validates `workingDir` locally and has no `caseName`). → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
**Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
**Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All five **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All six **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok)
**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 sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
@@ -252,7 +252,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a sixth `SessionMode`** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a `SessionMode` of its own** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
@@ -264,13 +264,13 @@ 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) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `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) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `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).
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) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `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) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `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).
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders. ⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the ≤430px block, because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it). ⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end. ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
**Session list layout: header strip or left sidebar** (`sessionListLayout`, App Settings → Appearance → Tabs, default `header`; per-device policy — it IS in `SettingsUpdateSchema` and persists server-side, but `displayKeys` makes a device keep its own value): with many sessions the horizontal strip stops being scannable, so the list can move into a vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). ⚠️ **There is ONE `#sessionTabs` element and it is MOVED between two hosts** (`#sessionTabsHost` in the header, `#sessionSidebarList` in the aside), never a second list — so every render path, drag-reorder handler and Alt+N index keeps working unchanged, and `applySessionListLayout()` is the only thing that reparents it. ⚠️ It sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes. ⚠️ Leaving sidebar mode **clears `_sidebarFilter`**: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the aside is an off-canvas overlay rather than a docked rail, and a closed drawer keeps `display: flex`, so it is marked `inert` + `aria-hidden` (`_isSessionSidebarOverlay()`) or its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable. The desktop home rail (`home-sessions.js`) defers to it, since both dock the session list flush left.
**Session list layout: header strip or left sidebar** (`sessionListLayout`, App Settings → Appearance → Tabs, default `header`; per-device policy — it IS in `SettingsUpdateSchema` and persists server-side, but `displayKeys` makes a device keep its own value): with many sessions the horizontal strip stops being scannable, so the list can move into a vertical `<aside>` with a filter box and a live count, collapsible to a 44px rail (`--sidebar-width` 260 / `--sidebar-width-collapsed` 44) via **Alt+B** (`toggleSessionSidebar`; Alt, not Ctrl+B, which must reach tmux/readline in the terminal). ⚠️ **There is ONE `#sessionTabs` element and it is MOVED between hosts** (`#sessionTabsHost` in the header, `#sessionSidebarList` in the aside, `#tabRail` for the vertical rail below), never a second list — so every render path, drag-reorder handler and Alt+N index keeps working unchanged. Exactly TWO functions reparent it and they must run in this order: `applySessionListLayout()` first (sidebar wins), then `applyTabOrientation()` (settings-ui.js), which moves the tabs into `#tabRail` only when the sidebar does not own them. ⚠️ It sets `data-session-list` / `data-sidebar` on `<html>` and must run BEFORE `applyTabWrapSettings()`, which is the one owner of `tabs-two-rows`/`tabs-show-folder` and reads those attributes. ⚠️ **The vertical tab rail** (`tabOrientation`/`tabRailWidth`/`sessionSidebarFontSize`, all per-device display keys that ARE in the schema, like `sessionListLayout`) is a SECOND vertical list next to the sidebar: the orientation setting is silently ignored while the sidebar layout is chosen, desktop/tablet only (`resolveTabOrientation` forces horizontal on mobile), resizable via `tab-rail-resize.js` (which owns terminal refits during the drag). ⚠️ **Axis decisions must use `_isVerticalTabList()`** (sidebar OR rail), never `isSessionSidebarActive()` alone: the rail leaves `data-session-list` at `header`, and the sidebar-only predicate shipped four rail bugs at once (drag insertion side read from clientX, active tab never scrolled into view, floating windows anchored below tabs instead of beside them, connector redraws skipped on rail scroll). The pre-paint script stamps `data-tab-orientation` (+ `--tab-rail-width`) like it stamps the sidebar keys, or vertical mode flashes through the header strip; the name font size defaults to 12px, the sidebar's historical size, so untouched installs are never restyled. ⚠️ Leaving sidebar mode **clears `_sidebarFilter`**: the filter box only exists in the aside, so a stale filter would hide sessions from the header strip with no reachable control to clear it. ⚠️ On handhelds the aside is an off-canvas overlay rather than a docked rail, and a closed drawer keeps `display: flex`, so it is marked `inert` + `aria-hidden` (`_isSessionSidebarOverlay()`) or its filter box and ~4 tab stops per session stay in the tab order; the DOCKED desktop rail must never be inerted, its rows are still clickable. The desktop home rail (`home-sessions.js`) defers to it, since both dock the session list flush left.
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
@@ -331,18 +331,18 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`) |
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`) |
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
**Security-relevant env vars**: `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS=1` (opt-in hooks-only listener on the docker bridge gateway).
### SSE Event Registry
155 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 155 = 155, no drift either direction). The backend file's `@fileoverview` carries the per-category breakdown.
156 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 156 = 156, no drift either direction). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events.
### API Routes
~217 handlers across 24 route files in `src/web/routes/`: system (48), sessions (34), cases (29), files (17), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (4), readmymind (4), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
~218 handlers across 24 route files in `src/web/routes/`: system (49), sessions (34), cases (29), files (17), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (4), readmymind (4), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), voice (1 + the `/ws/voice/stream` relay), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
+7 -7
View File
@@ -5,7 +5,7 @@
<h2 align="center">Mission control for AI coding agents</h2>
<p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Terminal - One Dashboard &bull; Any Device</em>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; Terminal - One Dashboard &bull; Any Device</em>
</p>
<p align="center">
@@ -27,7 +27,7 @@
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
</p>
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
Get started in one line (macOS & Linux, Windows via WSL):
@@ -42,7 +42,7 @@ codeman web
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
- **One dashboard, six CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
- **One dashboard, seven CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
@@ -68,7 +68,7 @@ This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, a
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist.
- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi](https://pi.dev) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the six is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install:
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the seven is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install:
```bash
codeman web
@@ -171,7 +171,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
wsl bash -c "curl -fsSL https://getcodeman.com/install | bash"
```
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi](https://pi.dev)). After installing, `http://localhost:3000` is accessible from your Windows browser.
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build)). After installing, `http://localhost:3000` is accessible from your Windows browser.
</details>
@@ -253,7 +253,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
| Field | What it does |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, or `Terminal` (plain shell). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, or `Terminal` (plain shell). |
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
@@ -437,7 +437,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, or **Pi** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md) and [`docs/pi-integration.md`](docs/pi-integration.md)
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, or **Grok** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md)
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
+5 -5
View File
@@ -5,7 +5,7 @@
<h2 align="center">AI 编程智能体的任务控制中心</h2>
<p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em>
</p>
<p align="center">
@@ -58,7 +58,7 @@ curl -fsSL https://getcodeman.com/install | bash
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli) 或 [Pi](https://pi.dev)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这六个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev) 或 [Grok Build](https://github.com/xai-org/grok-build)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这七个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
```bash
codeman web
@@ -141,7 +141,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
wsl bash -c "curl -fsSL https://getcodeman.com/install | bash"
```
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli) 或 [Pi](https://pi.dev))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev) 或 [Grok Build](https://github.com/xai-org/grok-build))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
</details>
@@ -221,7 +221,7 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
| 字段 | 作用 |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。 |
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi` 或 `Terminal`(普通 shell)。 |
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok` 或 `Terminal`(普通 shell)。 |
| **模型** | 每会话模型(App Settings → Claude Model)。软默认值 —— 会话内 `/model` 依然有效。 |
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
@@ -394,7 +394,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
## 更多特性
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini** 或 **Pi**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`PI_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md) 与 [`docs/pi-integration.md`](docs/pi-integration.md)
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi** 或 **Grok**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`PI_*`、`GROK_*`/`XAI_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md) 与 [`docs/grok-integration.md`](docs/grok-integration.md)
- **Docker 会话** —— 在隔离且加固的容器中运行案例。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一案例的多个会话共享一个容器;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
- **远程 SSH 会话**:把案例指向另一台机器,让智能体在那里一个持久的远程 tmux 中运行:SSH 断连不中断任务、自动重连,还能发现并附着主机上已在运行的会话。详见 [`docs/remote-sessions.md`](docs/remote-sessions.md)
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
+3
View File
@@ -19,6 +19,9 @@
* why these are a runnable suite (`npm run test:browser`) rather than skipped.
*/
export const BROWSER_TEST_GLOBS = [
'test/tab-rail-resize.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
'test/opencode-resize.test.ts',
'test/webgl-fallback.test.ts',
+20 -2
View File
@@ -51,6 +51,23 @@ RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent \
&& npm cache clean --force \
&& pi --version
# Grok Build (`grok`, xAI) is NOT on npm: a standalone ~160MB Rust binary through
# xAI's installer, which targets $HOME/.grok/bin with no --dir override. At build
# time that is root's home and unreachable by the `agent` user, so copy the binary
# into /usr/local/bin and drop root's ~/.grok in the same layer so the image does
# not carry the download twice. The staging cp -T is what makes this survive the
# installer's own behavior EITHER way: newer installers already symlink
# /usr/local/bin/grok -> /root/.grok/bin/grok, and a direct `cp -L` onto that
# symlink fails with "same file" (2026-08-24 rebuild), while removing the link
# first and copying fresh works for both old and new installers.
RUN curl -fsSL https://x.ai/cli/install.sh | bash \
&& cp -L /root/.grok/bin/grok /usr/local/bin/grok.real \
&& rm -f /usr/local/bin/grok \
&& mv /usr/local/bin/grok.real /usr/local/bin/grok \
&& chmod 755 /usr/local/bin/grok \
&& rm -rf /root/.grok /root/.local/bin/grok /root/.local/bin/agent \
&& grok --version
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
@@ -68,11 +85,12 @@ ENV HOME=/home/agent
# transcript/rollout dirs (`.claude/projects`, `.codex/sessions`) are bind-mounted from
# the host. (gemini/gcloud/opencode are whole seed-copies and need no pre-created dir;
# Antigravity nests its state inside `.gemini/antigravity-cli`, so it rides that seed.)
# `.pi/agent` IS pre-created: pi is seeded per-FILE (auth/settings/trust/models), and a
# `.pi/agent` and `.grok` ARE pre-created: both are seeded per-FILE (pi:
# auth/settings/trust/models; grok: auth.json/config.toml/pager.toml), and a
# per-file seed copy, unlike a whole-dir one, does not create its parent directory.
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
/home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent \
/home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \
&& chgrp -R 0 /home/agent \
&& chmod -R g=u /home/agent
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -91,7 +91,7 @@ These map 1:1 to `CronJobSchema` (`src/web/schemas.ts`) and the `CronJob` type
| Field | Required | Values / limits | Notes |
| -------------------------- | ----------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name` | ✅ | 1–200 chars | Display name; also used as the created session's name. |
| `agentType` | ✅ | `claude` \| `shell` \| `opencode` \| `codex` \| `gemini` \| `antigravity` \| `pi` | Reuses Codeman's `SessionMode`. `shell` = a plain terminal. ⚠️ A `pi` job's readiness poll looks for `❯`/a token count, neither of which pi prints, so it burns the poll budget and then sends the prompt anyway (slower start, still works). |
| `agentType` | ✅ | `claude` \| `shell` \| `opencode` \| `codex` \| `gemini` \| `antigravity` \| `pi` \| `grok` | Reuses Codeman's `SessionMode`. `shell` = a plain terminal. ⚠️ A `pi` or `grok` job's readiness poll looks for `❯`/a token count, which neither CLI prints, so it burns the poll budget and then sends the prompt anyway (slower start, still works). |
| `workingDir` | ✅ | valid path (allowlist-validated) | Validated at **create/update** (must exist, be a directory, and not resolve into a blocked tree — `/etc`, `/root`, `/proc`, `/sys`, `/dev`, or `/` itself) and again **at fire time**. |
| `launchCommand` | — | ≤ 2000 chars, single line | `shell` mode only: sent as the **first input line** once the shell is up, before the prompt. Ignored for other agent types. |
| `promptMode` | ✅ | `inline_text` \| `prompt_file_path` | See §5. |
+4 -4
View File
@@ -2,7 +2,7 @@
Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**.
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` all work inside the container.
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` / `grok` all work inside the container.
## One-time setup: build the base image
@@ -25,12 +25,12 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif
```bash
docker run --rm codeman/agent:base bash -lc \
'for c in claude codex gemini opencode agy pi; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
'for c in claude codex gemini opencode agy pi grok; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
```
Antigravity (`agy`) is the one CLI not installed from npm (Google ships a standalone binary), so it has its own Dockerfile step and adds roughly 190MB; a full image lands near 1.6GB. Pi also gets its own step, because upstream documents installing it with `--ignore-scripts` and that flag must not silently change how the other four npm CLIs install.
Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (Google and xAI ship standalone binaries), so each has its own Dockerfile step, adding roughly 190MB and 160MB respectively. Pi also gets its own step, because upstream documents installing it with `--ignore-scripts` and that flag must not silently change how the other npm CLIs install.
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md).
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md).
## Quickest path: one-click "Run in Docker"
+106
View File
@@ -0,0 +1,106 @@
# Grok Build (xAI) integration plan
> **Status**: Executed. This document records the plan, the decision behind each wiring
> point, and what was and was not verified. The user-facing guide is
> [`grok-integration.md`](./grok-integration.md); the per-decision invariants live in
> [`architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok`](./architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok).
> Template: the pi integration (`c5b5963`, [`pi-integration-plan.md`](./pi-integration-plan.md)),
> which was itself calibrated against the four follow-up commits the antigravity
> integration needed. All of grok's facts below were verified against **grok 1.0.5**
> (`grok 1.0.5 (5115b46bc9)`), installed live during the work.
## 1. What Grok Build is
[xai-org/grok-build](https://github.com/xai-org/grok-build) is xAI's coding agent: a
Rust fullscreen-TUI binary named `grok`, installed by
`curl -fsSL https://x.ai/cli/install.sh | bash` into `~/.grok/bin` (with symlinks into
`~/.local/bin`; the installer also ships an `agent` alias). Config lives in
`~/.grok/config.toml`, TUI appearance in `~/.grok/pager.toml`, credentials in
`~/.grok/auth.json` (0600), sessions under `~/.grok/sessions/`. Auth is browser OAuth
on first launch, `grok login --device-auth` for SSH boxes, or `XAI_API_KEY` for
headless use. It has Claude-style permission modes (`default`/`acceptEdits`/`auto`/
`dontAsk`/`bypassPermissions`/`plan`), allow/deny rules, hooks, MCP, subagents, and a
headless `-p` mode.
## 2. Shape decisions (why grok is wired the way it is)
Grok is a seventh run mode, alongside Claude Code, shell, OpenCode, Codex, Gemini,
Antigravity and Pi. Never a location overlay, never a web tab. Its wiring mixes two
existing shapes:
| Question | Decision | Why |
| --- | --- | --- |
| Permission bypass | `GrokConfig.alwaysApprove` -> `--always-approve` | Grok's real flag (verified via `--help`): "Auto-approve all tool executions", i.e. its `bypassPermissions` mode. Config-level deny rules still apply on top. The Run button sends `true`, matching `runAntigravity()` and Claude's own `--dangerously-skip-permissions` default: Codeman sessions exist for autonomous work. |
| Multi-user clamp branch | only-if-sent (codex/antigravity branch) | A bare `grok` spawn is grok's own ask-mode default, which is already safe, so the clamp only needs to force a SENT `alwaysApprove` off. Contrast pi, whose absent default is an answerable prompt and therefore needs the materialize branch. Cron needs nothing for grok for the same reason (`clampCronExternalCliConfigs`). |
| Alt-screen strip | OUT of `isAltScreenStripMode()` | Grok is a fullscreen alternate-screen TUI with mouse support (its own scrollback pane, `pager.toml [terminal] alt_screen`), i.e. the opencode case, not the Ink repaint case. It falls through to the narrow tmux-attach strip like opencode/antigravity/pi. |
| Resolver | version probe, like pi | `grok` has npm squatters (the unrelated `@vibe-kit/grok-cli` installs a `grok` bin). Candidates must pass `grok --version`; `GROK_VERSION_REGEX` is exported and shared with the dependency registry so doctor and run mode cannot disagree. The probe cannot tell two version-printing `grok`s apart, so `GET /api/grok/status` surfaces path AND version. Search dirs: `~/.grok/bin` first (installer target), then `~/.local/bin`, `/usr/local/bin`, `~/bin`. |
| Env allowlist | `GROK_*` + `XAI_*` prefixes | `GROK_*` covers grok's documented inputs (`GROK_HOME`, `GROK_CONFIG`/`GROK_CONFIG_PATH`, `GROK_MEMORY`, `GROK_WORKFLOWS`, `GROK_SANDBOX`, `GROK_OIDC_*`, `GROK_AUTH_PROVIDER_COMMAND`). `XAI_*` is xAI's vendor namespace and carries `XAI_API_KEY`, grok's documented headless auth var: the same narrow-vendor-namespace reasoning that admitted `GOOGLE_*` for gemini. Foreign provider keys stay out, as always. |
| Resume | `--resume <id>` / `--continue`, id-regexed | Grok's `--resume` also matches session TITLES (arbitrary user strings, case-insensitive). The `^[a-zA-Z0-9._-]+$` regex doubles as the no-titles rule, so nothing free-form can reach the `bash -c` spawn line. A valid explicit id wins over `-c`, mirroring pi. |
| Local echo | `'buffer'` via the `_updateLocalEchoState` fallthrough | UNMEASURED against an authenticated session (see §4). If grok's composer turns out per-keystroke reactive like codex's, the fallback is one `'off'` branch; teaching `PredictiveEchoAddon` grok's composer row is the larger follow-up. |
| Truecolor | `COLORTERM=truecolor` + `unset NO_COLOR` | Rust TUI with themes; joins the codex/gemini/antigravity/pi list in `buildEnvExports()` and `buildMuxAttachEnv()`. |
| Docker credentials | per-file seed: `auth.json`, `config.toml`, `pager.toml` | `~/.grok` also holds `sessions/`, `memory/`, `completions/`, `docs/` and the ~160MB binary under `downloads/`; a whole-dir seed would copy all of it on every container start. Same trade-off as pi: in-container sessions are invisible host-side, so `grok -c` in a Docker case sees only that container's history. |
| Docker install | own Dockerfile step | Not an npm package. xAI's installer has no `--dir` override, so the step copies `/root/.grok/bin/grok` (through the symlink, `cp -L`) into `/usr/local/bin` and removes root's `~/.grok` in the same layer. |
| Remote SSH | `exec "$SHELL" -i -l -c 'grok'` | sshd's remote-command PATH does not include `~/.grok/bin`; same login-shell fix as every other agent CLI. |
| What is NOT wired | `--permission-mode`, `--allow`/`--deny`, `-p` headless, `--worktree`, `--sandbox`, `--reasoning-effort`, `-s/--session-id`, `--fork-session`, `--agent`, `--output-format` | Follow-ups. The flag surface is kept minimal on purpose; grok is pre-1.0-style fast-moving and every flag added is a flag validated forever. |
## 3. Touch points (the checklist)
Backend: `types/session.ts` (SessionMode + GrokConfig + SessionState), `utils/grok-cli-resolver.ts` (new)
+ barrel, `tmux-manager.ts` (`buildGrokCommand`, dispatch, resume flag, PATH export, truecolor,
availability error, plumbing), `session.ts` (external-mode gate, label, config plumbing,
tmux-required error, attach env), `mux-interface.ts`, `schemas.ts` (prefixes, `GrokConfigSchema`,
both mode enums, remote command overrides, cron agentType), `session-routes.ts` (clamp + both
create paths), `system-routes.ts` (`GET /api/grok/status`), `server.ts` (availability inject +
mux restore), `docker-hosts.ts`, `remote-hosts.ts`, `config/dependency-registry.ts`,
`cron/cron-service.ts` (comment), `response-viewer-transcript.ts`, `tui/tui-client.ts` + `tui-app.ts`.
Frontend: `index.html` (welcome button, run-mode entry, cron option, clone Brain option),
`session-ui.js` (`runGrok()`, dispatch, availability, "Run GK" label, external-CLI gates,
runMode setter), `app.js` (label, `gk` tab badge, kill-menu), `settings-ui.js`,
`mobile-overview.js`, `home-sessions.js`, `panels-ui.js`, `i18n.js`, `styles.css` +
`mobile.css` (charcoal monochrome identity; the non-og skin block and the mobile
`!important` pair are both load-bearing, see the pi plan's §2.9 cascade trap).
Meta: `docker/agent.Dockerfile`, `install.sh`, `package.json` keyword, changeset,
`skills/codeman/reference/*`, CLAUDE.md, READMEs, `architecture-invariants.md`,
`remote-sessions.md`, `security-architecture.md`, `docker-cases.md`, `cron-guide.md`.
Tests: `test/grok-mode.test.ts` + `test/grok-cli-resolver.test.ts` (new);
`external-cli-bypass-clamp`, `system-routes`, `render-index-html`, `run-mode-ui`,
`mobile-overview`, `local-echo-codex-gating` (extended).
## 4. Verification performed
On this box, with grok 1.0.5 really installed and an isolated
`CODEMAN_INSTANCE=grokwt` server (own data dir, own tmux socket, port 5077):
1. `npm test` (the CI gate): green, 5900+ tests. `typecheck`, `lint`, `format:check`,
`check:frontend-syntax`, `check:public-assets`, `check:lockfile`: green.
2. `GET /api/grok/status` -> `{available: true, path: "/home/arkon/.local/bin", version: "1.0.5"}`
through the real resolver and probe.
3. `POST /api/quick-start {mode: "grok", grokConfig: {alwaysApprove: true}}` -> session
created, tmux pane spawned, real spawn line verified to end in `grok --always-approve`,
and the actual grok TUI rendered its OAuth device-approval screen in the pane
(unauthenticated box, so sign-in is exactly where a first run lands).
4. `grokConfig` persisted into the instance's `state.json`.
5. Session deleted by exact id; instance data dir and throwaway case removed.
**Not verified (honest gaps, all requiring an xAI account or more hardware):**
an authenticated conversation end to end; the local-echo buffer policy against grok's
real composer (§2); scrollback/repaint behavior of the fullscreen TUI under the narrow
strip during a long session; a Docker case with `mode: 'grok'` (needs a `--no-cache`
agent-image rebuild); a remote-SSH grok case; cron readiness degradation (expected:
same slow-start-then-send as pi, documented in `cron-guide.md`).
## 5. Follow-ups
- Idle/completion signal: grok has a hooks system (user-guide `10-hooks.md`); a hook
POSTing to `/api/hook-event` could give grok sessions real idle detection instead of
output-stabilization. Highest-value follow-up, same slot as pi's `agent_settled` idea.
- Response viewer: sessions are ACP JSONL under `~/.grok/sessions/<encoded-cwd>/<id>/updates.jsonl`;
`grok -p ... --output-format json | jq -r '.sessionId'` exists for correlation.
- Permission-mode picker (`--permission-mode`, `--allow`/`--deny`) in Session Options.
- Measure the local-echo policy and the fullscreen-TUI scrollback behavior against an
authenticated session; pin the result in `local-echo-codex-gating` the way pi did.
- `grok doctor` is a built-in terminal-support check worth pointing users at when a
pane renders oddly.
+133
View File
@@ -0,0 +1,133 @@
# Grok Build (xAI) sessions
Codeman can drive [Grok Build](https://github.com/xai-org/grok-build) (xAI's `grok`
CLI, the agent behind docs.x.ai/build) as a session backend, alongside Claude Code,
OpenCode, Codex, Gemini, Antigravity and Pi. `grok` is a seventh **run mode**: its own
PTY, its own tmux session, its own tab identity (monochrome charcoal, `gk` badge). It
is not a location overlay like Docker or remote-SSH cases, and it is not a web tab.
The design rationale behind each decision below lives in
[`grok-integration-plan.md`](./grok-integration-plan.md). Everything here was verified
against grok 1.0.5.
## Install
```bash
curl -fsSL https://x.ai/cli/install.sh | bash
```
The installer places the binary in `~/.grok/bin` and symlinks it into `~/.local/bin`
(it also installs an `agent` alias Codeman ignores). `grok update` self-updates.
Codeman resolves the binary via the server PATH and then the usual install locations,
`~/.grok/bin` first. **`grok` is a name with known squatters** (the unrelated
`@vibe-kit/grok-cli` npm package also installs a `grok` bin), so like `pi` the
resolver does not trust a PATH hit on its own: it runs `grok --version` once and
requires version-shaped output (`grok 1.0.5 (5115b46bc9)`). Check what it resolved:
```bash
curl -s localhost:3000/api/grok/status | jq
# { "available": true, "path": "/home/you/.grok/bin", "version": "1.0.5" }
```
The endpoint carries `version` on top of the sibling `/api/*/status` shape precisely
so a misresolution is visible rather than presenting as "the mode just doesn't work".
## Authenticate
- **Browser OAuth (default)**: the first `grok` run opens a sign-in flow; in a
Codeman pane you get the device-code screen with a URL to open elsewhere.
Credentials land in `~/.grok/auth.json` (0600) and refresh automatically.
- **Device code**: `grok login --device-auth`, made for SSH boxes and headless hosts.
- **API key**: `export XAI_API_KEY="xai-..."` (console.x.ai). Used as a fallback when
no session token exists. As a per-session Codeman `envOverride` it flows through
socket-scoped `tmux setenv`, never the spawn command line.
- **Enterprise OIDC**: `GROK_OIDC_ISSUER` / `GROK_OIDC_CLIENT_ID`.
## What Codeman wires up
`GrokConfig` (per session, persisted in `state.json`, round-trips through respawn):
| Field | Flag | Notes |
| ----------------- | --------------------------- | --------------------------------------------------------------------- |
| `model` | `--model <v>` | e.g. `grok-4.5`, or a custom `[model.<name>]` from `config.toml` |
| `alwaysApprove` | `--always-approve` | Grok's `bypassPermissions` mode; deny rules still apply on top |
| `continueSession` | `--continue` | Most recent session for the working directory; skipped when resuming |
| `resumeSessionId` | `--resume <v>` | Ids only, never titles (grok's own `--resume` also matches titles) |
Every value is regex-validated and **dropped** (not escaped) if it fails, because the
result is interpolated into the pane's `bash -c "..."` command.
The Run button sends `grokConfig: { alwaysApprove: true }`, the same product decision
as Claude's `--dangerously-skip-permissions` default and Antigravity's
`--dangerously-skip-permissions`: Codeman sessions exist for autonomous work. Keep
hard limits as `deny` rules in `~/.grok/config.toml` (they apply in every mode), and
in **multi-user mode** a non-granted owner's `alwaysApprove` is forced off
server-side; a bare `grok` spawn is grok's own ask-mode default.
Env overrides: the `GROK_*` prefix (`GROK_HOME`, `GROK_CONFIG`, `GROK_MEMORY`,
`GROK_WORKFLOWS`, `GROK_SANDBOX`, `GROK_OIDC_*`, ...) plus the `XAI_*` vendor
namespace (`XAI_API_KEY`) are allowlisted. Foreign provider keys are not, as ever.
## What Codeman deliberately does NOT wire up
- **`--permission-mode`, `--allow`/`--deny`.** The boolean covers the autonomous
case; the full rule surface is a follow-up with UI.
- **`-p`/headless, `--output-format`, `--json-schema`.** Codeman drives the TUI.
- **`--worktree`, `--sandbox`, `--reasoning-effort`, `-s/--session-id`,
`--fork-session`, `--agent`/`--agents`.** Tracked as follow-ups in the plan doc.
## Terminal behavior
Grok renders a **fullscreen alternate-screen TUI** (scrollback pane + prompt, mouse
supported). Under Codeman it runs inside tmux like every external CLI, so the
fullscreen rendering stays inside the pane and the browser terminal shows tmux's
repaints; grok stays out of the alt-screen strip list on purpose (the opencode case,
not the Ink case). If a pane renders oddly, `grok doctor` checks terminal, color and
input support without starting a session, and `~/.grok/pager.toml` can force
`alt_screen = "inline"`.
On touch devices grok currently gets the buffered local-echo overlay like Claude,
Gemini, OpenCode and Pi. This is the fallthrough default and has not been measured
against an authenticated grok composer; if grok turns out per-keystroke reactive the
way codex was (issues #218/#219/#220/#222), the fix is the `'off'` branch in
`_updateLocalEchoState` (terminal-ui.js).
## Docker cases
The agent image installs grok in its own Dockerfile step (not npm; xAI's installer
targets `$HOME/.grok/bin` with no `--dir` override, so the binary is copied to
`/usr/local/bin`). Rebuild with the mandatory `--no-cache`:
```bash
node scripts/build-agent-image.mjs --no-cache
```
Credentials are **seeded**, not shared: `auth.json`, `config.toml` and `pager.toml`
are copied into the container's own `~/.grok`, so an in-container grok never writes
refreshed OAuth tokens back to the host and `docker commit` exports stay secret-free.
Only those three files, because `~/.grok` also holds `sessions/`, `memory/` and the
~160MB binary under `downloads/`. Trade-off, same as pi: in-container sessions are
invisible host-side, so `grok -c` inside a Docker case only sees that container's own
history.
## Remote SSH cases
`grok` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'grok'`), because sshd's remote-command PATH does not include
`~/.grok/bin`. Per-session config and `envOverrides` do not cross ssh and are rejected
rather than silently ignored; use the per-host command override instead. For auth on
the remote host, `grok login --device-auth` exists for exactly this.
## Known gaps
- **No idle/completion hook yet.** Idle detection falls back to output-stabilization
like the other external CLIs. Grok has a hooks system, so a Codeman hook POSTing to
`/api/hook-event` is the highest-value follow-up.
- **No response viewer.** Grok writes ACP JSONL sessions under
`~/.grok/sessions/<encoded-cwd>/<session-id>/updates.jsonl`; nothing reads them yet.
- **Cron jobs mis-detect readiness.** The readiness poll looks for `❯` or a token
count, neither of which grok prints, so a grok cron job burns its poll budget and
then sends the prompt anyway. It works; it is just slower to start.
- **Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe are
off** for grok, as for every external CLI.
+2 -2
View File
@@ -1,7 +1,7 @@
# Remote Sessions (SSH)
Codeman can run a session's agent on a **remote host over SSH** instead of the
local machine. The agent (Claude, OpenCode, Codex, Antigravity, Gemini, Pi, or a plain shell)
local machine. The agent (Claude, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or a plain shell)
runs inside a `tmux` server **on the remote host**, so it survives the SSH
connection dropping; Codeman attaches to it the same way it attaches to a local
managed session.
@@ -30,7 +30,7 @@ Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi'>` — the modes that can run remotely. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok'>` — the modes that can run remotely. |
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
Persistence is two flat JSON arrays in the instance data dir:
+1 -1
View File
@@ -489,7 +489,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, and five seeded files from `~/.pi/agent`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, and three from `~/.grok`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
+1 -1
View File
@@ -119,7 +119,7 @@ Shell and external CLI sessions accept `idle`, `working`, and `exit`.
## SSE
`GET /api/events` is the live event stream. 155 event names, kept in sync between server and
`GET /api/events` is the live event stream. 156 event names, kept in sync between server and
client with a test that fails on drift.
The heartbeat is a **named** `sse:heartbeat` event rather than an SSE comment, because
+50 -4
View File
@@ -125,6 +125,14 @@ PI_SEARCH_PATHS=(
"$HOME/bin/pi"
)
# Grok CLI search paths (from src/utils/grok-cli-resolver.ts)
GROK_SEARCH_PATHS=(
"$HOME/.grok/bin/grok"
"$HOME/.local/bin/grok"
"/usr/local/bin/grok"
"$HOME/bin/grok"
)
# Antigravity CLI search paths (from src/utils/antigravity-cli-resolver.ts)
ANTIGRAVITY_SEARCH_PATHS=(
"$HOME/.local/bin/agy"
@@ -569,6 +577,37 @@ get_pi_path() {
done
}
# `grok` has known squatters too (the unrelated @vibe-kit/grok-cli), so the
# server-side resolver additionally probes `grok --version`. Detection here only
# feeds the "you have no AI CLI" hint, so a plain executable test is enough.
check_grok() {
if command -v grok &>/dev/null; then
return 0
fi
for path in "${GROK_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_grok_path() {
if command -v grok &>/dev/null; then
command -v grok
return
fi
for path in "${GROK_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
check_cloudflared() {
# Check ~/.local/bin first (matches tunnel-manager.ts resolution order)
if [[ -x "$HOME/.local/bin/cloudflared" ]]; then
@@ -2083,6 +2122,7 @@ main() {
local has_gemini=false
local has_antigravity=false
local has_pi=false
local has_grok=false
info "Checking AI CLI tools..."
if check_claude; then
@@ -2109,17 +2149,21 @@ main() {
has_pi=true
success "Pi CLI found at $(get_pi_path)"
fi
if check_grok; then
has_grok=true
success "Grok CLI found at $(get_grok_path)"
fi
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" ]]; then
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" ]]; then
echo ""
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi."
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok."
headless_guard "install an AI CLI (curl | bash from its vendor)"
echo ""
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)"
echo -e " ${CYAN}2)${NC} OpenCode (open-source)"
echo -e " ${CYAN}3)${NC} Both"
echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity or Pi)"
echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity, Pi or Grok)"
echo ""
local cli_choice=""
@@ -2167,6 +2211,7 @@ main() {
info "Install one later, e.g.: npm install -g @openai/codex (Codex)"
info " or: curl -fsSL https://antigravity.google/cli/install.sh | bash (Antigravity)"
info " or: npm install -g --ignore-scripts @earendil-works/pi-coding-agent (Pi)"
info " or: curl -fsSL https://x.ai/cli/install.sh | bash (Grok)"
elif [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
fi
@@ -2467,13 +2512,14 @@ main() {
echo -e " https://github.com/Ark0N/Codeman"
echo ""
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi; then
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok; then
echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:"
echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code"
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
echo -e " ${CYAN}npm install -g @openai/codex${NC} # Codex"
echo -e " ${CYAN}curl -fsSL https://antigravity.google/cli/install.sh | bash${NC} # Antigravity"
echo -e " ${CYAN}npm install -g --ignore-scripts @earendil-works/pi-coding-agent${NC} # Pi"
echo -e " ${CYAN}curl -fsSL https://x.ai/cli/install.sh | bash${NC} # Grok"
echo ""
fi
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.21.0",
"version": "1.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.21.0",
"version": "1.22.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.21.0",
"version": "1.22.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",
@@ -62,6 +62,7 @@
"codex",
"antigravity",
"pi",
"grok",
"gemini-cli",
"ai-agents",
"agent",
+2
View File
@@ -86,6 +86,7 @@ run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --
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 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');
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite');
@@ -111,6 +112,7 @@ console.log('\n[build] content-hash cache busting');
'input-cjk.js',
'sanitize-html.js',
'app.js',
'tab-rail-resize.js',
'terminal-ui.js',
'respawn-ui.js',
'ralph-panel.js',
+12 -11
View File
@@ -237,7 +237,7 @@ minutes, never retry the credential.
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
returns is too early (verified live: empty on the first call, full prose seconds later).
It is also `""` before the worker's first completed turn, and permanently `""` for
`shell`, `opencode`, `gemini`, `antigravity` and `pi`, which write no Claude transcript.
`shell`, `opencode`, `gemini`, `antigravity`, `pi` and `grok`, which write no Claude transcript.
**Fix** Poll it, bounded (10 tries, 1 s apart). If it is still empty on a hook-less mode,
that is expected, not a failure: read `terminal?tail=` and strip ANSI instead.
@@ -336,19 +336,20 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi`; response is
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name.
⚠️ A `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`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`
and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed).
Pi's also carries `.data.version`, because `pi` is a short generic name that an unrelated
binary on `$PATH` can shadow: the resolver rejects one whose `--version` is not
semver-shaped, so `available:false` there can mean "a different `pi` is in front" rather
than "nothing is installed". `shell` has no CLI to probe.
Pi's and grok's also carry `.data.version`, because `pi` is a short generic name and
`grok` is a name with npm squatters, so an unrelated binary on `$PATH` can shadow either:
the resolver rejects one whose `--version` is not version-shaped, so `available:false`
there can mean "a different `pi`/`grok` is in front" rather than "nothing is installed".
`shell` has no CLI to probe.
⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field
is absent, `jq -r` prints the literal string `null`, and every later call then targets
@@ -462,10 +463,10 @@ Quirks that will bite you:
session answers with an empty timeline rather than a 404.
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:2136`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:165-167`, lists only those five), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:88`) matches
returns early for every external CLI mode (`session.ts:2261`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:174-183`, lists only those six), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
a shell worker running `cat build.log` really does populate this. In practice it stays
empty for most shell work. It also never sees non-Bash
+2 -2
View File
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`) | HTTP only (no other CLI has messaging) |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume
@@ -347,7 +347,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`) cannot be peers
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one
+1 -1
View File
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and
# always "" for shell/opencode/gemini/antigravity/pi, which have no transcript, use
# always "" for shell/opencode/gemini/antigravity/pi/grok, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
+3 -3
View File
@@ -343,7 +343,7 @@ recovered by submitting it with `{"input":"\r"}`.
⚠️ `stop` and `blocked` fire for `claude` sessions only (they are Claude Code hooks,
and only when the workspace actually has them, see [§5.1](#51-where-to-spawn)). On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`, requesting them explicitly is a
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`, requesting them explicitly is a
400, and lifecycle transitions there are coarse (a short shell command may emit **no**
`idle` transition at all, verified live), so synchronize those with markers.
@@ -369,7 +369,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
single read taken the instant send-and-wait returns comes back `""` even though the
turn finished (verified live: empty on the first call, full text seconds later). `text`
is also `""` before the worker's first completed turn, and always `""` for modes with
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`; the first four
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`; the first four
verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. Fall back to the terminal buffer
there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive
@@ -454,7 +454,7 @@ turn), and both better than diffing terminal samples:
```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
`opencode`/`codex`/`gemini`/`antigravity`/`pi`** (those parsers are skipped wholesale) and
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
+24
View File
@@ -8,6 +8,7 @@
*/
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
@@ -139,6 +140,29 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
},
],
},
{
id: 'grok',
label: 'Grok CLI',
category: 'core',
required: false,
usedBy: ['Grok sessions'],
// Version match required for the same reason as pi: `grok` has known squatters
// (the unrelated @vibe-kit/grok-cli npm package also installs a `grok` bin), so a
// bare `which grok` hit is not the coding agent. Both sides share
// GROK_VERSION_REGEX, so the doctor and the run mode cannot drift.
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: ['grok'],
versionArg: '--version',
versionRegex: GROK_VERSION_REGEX,
requireVersionMatch: true,
},
},
],
},
{
id: 'libreoffice',
label: 'LibreOffice',
+3 -2
View File
@@ -48,7 +48,8 @@ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
* answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript,
* so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve`
* is NOT a clamp.
* Codex and antigravity need nothing here: their absent config already spawns safe.
* Codex, antigravity and grok need nothing here: their absent config already spawns safe
* (grok's bare spawn is its own ask-mode default; --always-approve is only ever sent).
* Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched.
*/
export function clampCronExternalCliConfigs(
@@ -425,7 +426,7 @@ export class CronService {
piConfig,
owner: job.owner,
});
this.deps.addSession(session);
await this.deps.addSession(session);
this.store.incrementSessionsCreated();
this.deps.persistSessionState(session);
await this.deps.setupSessionListeners(session);
+11
View File
@@ -145,6 +145,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
gemini: 'exec gemini',
antigravity: 'exec agy',
pi: 'exec pi',
grok: 'exec grok',
};
return commands[mode as DockerCommandMode] || commands.shell;
}
@@ -614,6 +615,16 @@ const CRED_STORES: CredStorePolicy[] = [
rel: '.pi/agent',
seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'],
},
// Grok (xAI) keeps auth + config in `~/.grok`, but that dir ALSO holds
// `sessions/`, `memory/`, `downloads/` (the ~100MB binary itself) and `bin/`,
// so seedWhole would copy all of it into every container start. Seed only what
// grok needs to authenticate and behave consistently. Same trade-off as pi:
// in-container grok sessions are invisible host-side, so `grok -c` inside a
// Docker case only sees that container's own history.
{
rel: '.grok',
seedFiles: ['auth.json', 'config.toml', 'pager.toml'],
},
{ rel: '.config/gcloud', seedWhole: true },
{ rel: '.config/opencode', seedWhole: true },
];
+3
View File
@@ -19,6 +19,7 @@ import type {
GeminiConfig,
AntigravityConfig,
PiConfig,
GrokConfig,
SessionRemote,
SessionDocker,
} from './types.js';
@@ -78,6 +79,7 @@ export interface CreateSessionOptions {
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
@@ -110,6 +112,7 @@ export interface RespawnPaneOptions {
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
+1
View File
@@ -114,6 +114,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
gemini: remoteLoginShellCommand('gemini'),
antigravity: remoteLoginShellCommand('agy'),
pi: remoteLoginShellCommand('pi'),
grok: remoteLoginShellCommand('grok'),
};
return commands[mode as RemoteCommandMode] || commands.shell;
}
+35 -4
View File
@@ -51,6 +51,7 @@ import {
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type SessionRemote,
type SessionDocker,
} from './types.js';
@@ -171,7 +172,14 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
export function isExternalCliMode(mode: SessionMode): boolean {
return mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi';
return (
mode === 'opencode' ||
mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok'
);
}
function getModeLabel(mode: SessionMode): string {
@@ -186,6 +194,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Antigravity';
case 'pi':
return 'Pi';
case 'grok':
return 'Grok';
case 'shell':
return 'Shell';
case 'claude':
@@ -202,8 +212,9 @@ function getModeLabel(mode: SessionMode): string {
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
* vim/less/htop legitimately need the alt screen), `opencode` (renders its own
* TUI that may rely on it) and `pi` (below). Keep parity with the replay-side
* strip in session-routes.ts.
* TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI
* with mouse support, i.e. the opencode case, not the Ink case). Keep parity
* with the replay-side strip in session-routes.ts.
*
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
@@ -508,6 +519,8 @@ export class Session extends EventEmitter {
private _antigravityConfig: AntigravityConfig | undefined;
// Pi configuration (only for mode === 'pi')
private _piConfig: PiConfig | undefined;
// Grok configuration (only for mode === 'grok')
private _grokConfig: GrokConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -603,6 +616,8 @@ export class Session extends EventEmitter {
antigravityConfig?: AntigravityConfig;
/** Pi configuration (only for mode === 'pi') */
piConfig?: PiConfig;
/** Grok configuration (only for mode === 'grok') */
grokConfig?: GrokConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
@@ -712,6 +727,11 @@ export class Session extends EventEmitter {
this._piConfig = config.piConfig;
}
// Apply Grok configuration
if (config.grokConfig) {
this._grokConfig = config.grokConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
@@ -1304,6 +1324,7 @@ export class Session extends EventEmitter {
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
@@ -1476,7 +1497,11 @@ export class Session extends EventEmitter {
// COD-75: codex/gemini/antigravity/pi get COLORTERM=truecolor — mirrors buildEnvExports()
// in tmux-manager.ts so the attach client and the tmux session agree.
env: buildMuxAttachEnv(
this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity' || this.mode === 'pi'
this.mode === 'codex' ||
this.mode === 'gemini' ||
this.mode === 'antigravity' ||
this.mode === 'pi' ||
this.mode === 'grok'
),
})
);
@@ -1546,6 +1571,7 @@ export class Session extends EventEmitter {
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1804,6 +1830,7 @@ export class Session extends EventEmitter {
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1893,6 +1920,10 @@ export class Session extends EventEmitter {
if (this.mode === 'pi') {
throw new Error('Pi sessions require tmux. Direct PTY fallback is not supported.');
}
// Grok sessions require tmux for XAI_API_KEY / GROK_* injection via setenv
if (this.mode === 'grok') {
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.');
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
+60 -14
View File
@@ -40,6 +40,8 @@ import {
} from './types.js';
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
import { dataPath, CODEMAN_INSTANCE } from './config/instance.js';
import { normalizeSessionOrder } from './session-order.js';
import { validateTabLayout, type TabLayout } from './tab-layout.js';
/** Debounce delay for batching state writes (ms) */
const SAVE_DEBOUNCE_MS = 500;
@@ -281,6 +283,9 @@ export class StateStore {
if (this.state.sessionOrder) {
parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`);
}
if (this.state.tabLayouts !== undefined) {
parts.push(`"tabLayouts":${JSON.stringify(this.state.tabLayouts)}`);
}
return `{${parts.join(',')}}`;
}
@@ -514,22 +519,28 @@ export class StateStore {
*/
cleanupStaleSessions(activeSessionIds: Set<string>): {
count: number;
cleaned: Array<{ id: string; name?: string }>;
cleaned: Array<{ id: string; name?: string; owner?: string }>;
} {
const allSessionIds = Object.keys(this.state.sessions);
const cleaned: Array<{ id: string; name?: string }> = [];
const staleIds = new Set(Object.keys(this.state.sessions).filter((sessionId) => !activeSessionIds.has(sessionId)));
return this.cleanupSessionsByIds(staleIds);
}
for (const sessionId of allSessionIds) {
if (!activeSessionIds.has(sessionId)) {
if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session
const name = this.state.sessions[sessionId]?.name;
cleaned.push({ id: sessionId, name });
delete this.state.sessions[sessionId];
this.cachedSessionJsons.delete(sessionId);
this.dirtySessions.delete(sessionId);
// Also clean up Ralph state for this session
this.ralphStates.delete(sessionId);
}
/** Deletes only confirmed stale session IDs, retaining records pinned after confirmation. */
cleanupSessionsByIds(sessionIds: ReadonlySet<string>): {
count: number;
cleaned: Array<{ id: string; name?: string; owner?: string }>;
} {
const cleaned: Array<{ id: string; name?: string; owner?: string }> = [];
for (const sessionId of sessionIds) {
const session = this.state.sessions[sessionId];
if (!session || session.pinned === true) continue; // COD-142: pinned records persist even with no live session
cleaned.push({ id: sessionId, name: session.name, owner: session.owner });
delete this.state.sessions[sessionId];
this.cachedSessionJsons.delete(sessionId);
this.dirtySessions.delete(sessionId);
// Also clean up Ralph state for this session
this.ralphStates.delete(sessionId);
}
if (cleaned.length > 0) {
@@ -664,6 +675,41 @@ export class StateStore {
this.save();
}
/** Returns an owner layout, or null before that owner has been migrated. */
getTabLayout(owner: string): TabLayout | null {
const layouts = this.state.tabLayouts;
return layouts && Object.hasOwn(layouts, owner) ? layouts[owner] : null;
}
/** Returns a defensive snapshot of every stored owner layout. */
getTabLayouts(): Record<string, TabLayout> {
return structuredClone(this.state.tabLayouts ?? {});
}
/** Validates and atomically persists one owner layout. */
setTabLayout(owner: string, layout: TabLayout): void {
const validated = validateTabLayout(layout);
this.state.tabLayouts = { ...(this.state.tabLayouts ?? {}), [owner]: validated };
this.save();
}
/** Atomically publishes validated owner layouts and their latest global compatibility projection. */
commitTabLayoutProjection(
layouts: Readonly<Record<string, TabLayout>>,
projectOrder: (latest: readonly string[]) => readonly string[]
): { layouts: Record<string, TabLayout>; sessionOrder: string[] } {
const validated = Object.fromEntries(
Object.entries(layouts).map(([owner, layout]) => [owner, validateTabLayout(layout)])
);
const sessionOrder = normalizeSessionOrder(projectOrder([...(this.state.sessionOrder ?? [])]));
const nextLayouts = { ...(this.state.tabLayouts ?? {}), ...validated };
this.state.tabLayouts = nextLayouts;
this.state.sessionOrder = sessionOrder;
this.save();
return { layouts: structuredClone(validated), sessionOrder: [...sessionOrder] };
}
/** Resets all state to initial values and saves immediately. */
reset(): void {
this.state = createInitialState();
+81
View File
@@ -0,0 +1,81 @@
/**
* @fileoverview Pure compatibility translation between legacy session order and owner tab layouts.
*/
import { mergeSessionOrder, normalizeSessionOrder } from './session-order.js';
import {
normalizeTabLayout,
validateTabLayout,
type TabLayout,
type TabRef,
type TabRefMetadata,
} from './tab-layout.js';
export interface OwnerOrderProjection {
owner: string;
ownedIds: readonly string[];
order: readonly string[];
}
export function applyLegacySessionRank(
input: TabLayout,
requestedOrder: readonly string[],
metadata: readonly TabRefMetadata[]
): TabLayout {
const layout = validateTabLayout(input);
const requestedRank = new Map(normalizeSessionOrder(requestedOrder).map((id, index) => [id, index]));
const sessionMetadata = new Map<string, TabRefMetadata>();
for (const item of metadata) {
if (item.kind !== 'session' || !item.ownerValid || !item.visible || sessionMetadata.has(item.id)) continue;
sessionMetadata.set(item.id, item);
}
const isRanked = (ref: TabRef): boolean =>
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id);
const prepare = (ref: TabRef): TabRef => {
if (ref.kind !== 'session') return { ...ref };
const item = sessionMetadata.get(ref.id);
const ownerValidParent = item?.parentSessionId && sessionMetadata.has(item.parentSessionId);
return ownerValidParent ? { ...ref, placement: 'manual' } : { ...ref };
};
const rankContainer = (refs: readonly TabRef[]): TabRef[] => {
const ranked = refs
.filter(isRanked)
.map(prepare)
.sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
let rankedIndex = 0;
return refs.map((ref) => (isRanked(ref) ? ranked[rankedIndex++] : { ...ref }));
};
const transformed: TabLayout = {
...layout,
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
ungrouped: rankContainer(layout.ungrouped),
};
return normalizeTabLayout(transformed, metadata);
}
export function recomposeGlobalSessionOrder(
current: readonly string[],
projections: readonly OwnerOrderProjection[],
preferred?: readonly string[]
): string[] {
let result = mergeSessionOrder([...(preferred ?? current)], [...current]);
for (const projection of projections) {
const ownedIds = normalizeSessionOrder(projection.ownedIds);
const owned = new Set(ownedIds);
const canonical = normalizeSessionOrder(projection.order).filter((id) => owned.has(id));
const canonicalSet = new Set(canonical);
for (const id of ownedIds) {
if (canonicalSet.has(id)) continue;
canonicalSet.add(id);
canonical.push(id);
}
let canonicalIndex = 0;
const recomposed = result.map((id) => (owned.has(id) ? canonical[canonicalIndex++] : id));
recomposed.push(...canonical.slice(canonicalIndex));
result = normalizeSessionOrder(recomposed);
}
return result;
}
+144
View File
@@ -0,0 +1,144 @@
/**
* @fileoverview Owner-scoped tab-layout persistence and legacy migration primitives.
*
* This module is deliberately independent of routes and runtime managers. Callers
* provide persisted/live session facts plus saved webviews in server-store order.
*/
import { normalizeTabLayout, type TabLayout, type TabRef, type TabRefMetadata } from './tab-layout.js';
export const SINGLE_USER_LAYOUT_OWNER = '@single';
export interface TabLayoutSessionRecord {
id: string;
owner?: string;
createdAt: number;
parentSessionId?: string;
}
export interface TabLayoutWebviewRecord {
id: string;
owner?: string;
}
export interface TabLayoutMigrationInput {
owner: string;
layouts?: Readonly<Record<string, TabLayout>>;
sessionOrder?: readonly string[];
persistedSessions: readonly TabLayoutSessionRecord[];
liveSessions: readonly TabLayoutSessionRecord[];
/** Saved webviews in authoritative server-store order. */
webviews: readonly TabLayoutWebviewRecord[];
/** Required only when creating a layout, making migration deterministic in tests. */
updatedAt?: string;
}
export interface TabLayoutMigrationResult {
layout: TabLayout;
layouts: Record<string, TabLayout>;
created: boolean;
}
/** Resolve the persistence key without accepting an owner key from a client. */
export function ownerLayoutKey(username?: string): string {
return username || SINGLE_USER_LAYOUT_OWNER;
}
function recordOwner(record: { owner?: string }): string {
return record.owner ?? SINGLE_USER_LAYOUT_OWNER;
}
function compareSessions(a: TabLayoutSessionRecord, b: TabLayoutSessionRecord): number {
return a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
}
function collectSessions(input: TabLayoutMigrationInput): Map<string, TabLayoutSessionRecord> {
const sessions = new Map<string, TabLayoutSessionRecord>();
for (const record of input.persistedSessions) sessions.set(record.id, { ...record });
// A matching live record is authoritative as a whole. In particular, absent
// optional owner/parent fields mean single-user ownership and root lineage;
// retaining those fields from a stale persisted copy changes their semantics.
for (const record of input.liveSessions) sessions.set(record.id, { ...record });
return sessions;
}
function buildMetadata(
input: TabLayoutMigrationInput,
sessions: ReadonlyMap<string, TabLayoutSessionRecord>
): TabRefMetadata[] {
const ownerSessions = [...sessions.values()]
.filter((record) => recordOwner(record) === input.owner)
.sort(compareSessions);
const sessionOrder = new Map(ownerSessions.map((record, index) => [record.id, index]));
const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({
kind: 'session',
id: record.id,
ownerValid: recordOwner(record) === input.owner,
visible: true,
order: sessionOrder.get(record.id) ?? record.createdAt,
parentSessionId: record.parentSessionId,
}));
const webviewOffset = ownerSessions.length;
input.webviews.forEach((record, index) => {
metadata.push({
kind: 'webview',
id: record.id,
ownerValid: recordOwner(record) === input.owner,
visible: true,
order: webviewOffset + index,
});
});
return metadata;
}
/**
* Normalize an existing owner layout, or idempotently migrate legacy flat order.
* Unknown stored refs remain unknown to metadata and are therefore preserved.
* No input object is mutated; validation/capacity failure is atomic.
*/
export function normalizeOrMigrateOwnerTabLayout(input: TabLayoutMigrationInput): TabLayoutMigrationResult {
const sessions = collectSessions(input);
const metadata = buildMetadata(input, sessions);
const existing = input.layouts && Object.hasOwn(input.layouts, input.owner) ? input.layouts[input.owner] : undefined;
if (existing) {
const layout = normalizeTabLayout(existing, metadata);
return { layout, layouts: { ...(input.layouts ?? {}), [input.owner]: layout }, created: false };
}
const ownerSessions = [...sessions.values()].filter((record) => recordOwner(record) === input.owner);
const ownerSessionById = new Map(ownerSessions.map((record) => [record.id, record]));
const liveOwnerIds = new Set(
input.liveSessions.filter((record) => recordOwner(record) === input.owner).map((record) => record.id)
);
const seen = new Set<string>();
const orderedSessions: TabLayoutSessionRecord[] = [];
for (const id of input.sessionOrder ?? []) {
const record = ownerSessionById.get(id);
if (!record || seen.has(id)) continue;
seen.add(id);
orderedSessions.push(record);
}
for (const record of ownerSessions.filter((item) => !seen.has(item.id)).sort(compareSessions)) {
seen.add(record.id);
orderedSessions.push(record);
}
const refs: TabRef[] = orderedSessions.map((record) => {
const manual = record.parentSessionId !== undefined && liveOwnerIds.has(record.parentSessionId);
return manual ? { kind: 'session', id: record.id, placement: 'manual' } : { kind: 'session', id: record.id };
});
for (const webview of input.webviews) {
if (recordOwner(webview) === input.owner) refs.push({ kind: 'webview', id: webview.id });
}
const layout = normalizeTabLayout(
{
version: 0,
groups: [],
ungrouped: refs,
updatedAt: input.updatedAt ?? new Date().toISOString(),
},
metadata
);
return { layout, layouts: { ...(input.layouts ?? {}), [input.owner]: layout }, created: true };
}
+678
View File
@@ -0,0 +1,678 @@
/**
* @fileoverview Owner-scoped authoritative tab-layout coordination.
*
* This is the single mutation boundary between the pure layout model, persisted
* state, live sessions, saved webviews, and SSE. Lifecycle callers describe one
* completed server action; this service performs at most one versioned write.
*/
import type { StateStore } from './state-store.js';
import { mergeSessionOrder, normalizeSessionOrder } from './session-order.js';
import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layout-legacy-order.js';
import {
flattenOwnerSessionOrder,
materializeOrphans,
normalizeTabLayout,
TabLayoutValidationError,
validateTabLayout,
type TabLayout,
type TabRef,
type TabRefMetadata,
} from './tab-layout.js';
import {
normalizeOrMigrateOwnerTabLayout,
SINGLE_USER_LAYOUT_OWNER,
type TabLayoutSessionRecord,
type TabLayoutWebviewRecord,
} from './tab-layout-persistence.js';
import { SseEvent } from './web/sse-events.js';
export interface TabLayoutSessionLike {
id: string;
owner?: string;
createdAt: number;
parentSessionId?: string;
}
interface TabLayoutServiceDeps {
store: Pick<
StateStore,
'getTabLayout' | 'getTabLayouts' | 'getSessions' | 'getSessionOrder' | 'commitTabLayoutProjection'
>;
sessions: ReadonlyMap<string, TabLayoutSessionLike>;
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
broadcast(event: string, data: unknown): void;
broadcastSessionOrder(change: SessionOrderProjectionChange): void;
now?: () => string;
}
export type TabLayoutPutResult = { status: 'updated'; layout: TabLayout } | { status: 'conflict'; layout: TabLayout };
export interface LegacyOrderActor {
owner: string;
isAdmin: boolean;
}
export interface SessionOrderProjectionChange {
changedOwnerOrders: Record<string, string[]>;
globalOrder: string[];
globalChanged: boolean;
}
export interface LegacyOrderPutResult extends SessionOrderProjectionChange {
order: string[];
}
export interface RemovedTabLayoutSession {
id: string;
owner?: string;
}
interface PreparedOwnerLayout {
current: TabLayout | null;
authoritative: TabLayout;
metadata: TabRefMetadata[];
needsReconciliationCommit: boolean;
}
interface OwnerProjectionPublication {
owner: string;
previous: TabLayout | null;
next: TabLayout;
metadata: readonly TabRefMetadata[];
excludedSessionIds?: ReadonlySet<string>;
}
interface PreparedOrderProjection {
owner: string;
previousOrder: string[];
authoritativeBeforeIds: string[];
excludedIds: string[];
currentIds: string[];
order: string[];
}
const ownerOf = (record: { owner?: string }): string => record.owner ?? SINGLE_USER_LAYOUT_OWNER;
const refKey = (ref: Pick<TabRef, 'kind' | 'id'>): string => `${ref.kind}\u0000${ref.id}`;
const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) === JSON.stringify(b);
const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
a.length === b.length && a.every((id, index) => id === b[index]);
export class TabLayoutService {
private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending';
private readonly ownerQueues = new Map<string, Promise<void>>();
constructor(private readonly deps: TabLayoutServiceDeps) {}
private async withOwner<T>(owner: string, task: () => Promise<T>): Promise<T> {
const previous = this.ownerQueues.get(owner) ?? Promise.resolve();
const run = previous.catch(() => undefined).then(task);
const tail = run.then(
() => undefined,
() => undefined
);
this.ownerQueues.set(owner, tail);
try {
return await run;
} finally {
if (this.ownerQueues.get(owner) === tail) this.ownerQueues.delete(owner);
}
}
/** Acquire multiple owner queues in stable order so overlapping bulk cleanups cannot deadlock. */
private async withOwners<T>(owners: readonly string[], task: () => Promise<T>, index = 0): Promise<T> {
if (index >= owners.length) return task();
return this.withOwner(owners[index], () => this.withOwners(owners, task, index + 1));
}
markRestorationComplete(): void {
this.restorationState = 'complete';
}
markRestorationFailed(): void {
this.restorationState = 'failed';
}
markRestorationSkipped(): void {
this.restorationState = 'skipped';
}
assertDeletionReady(): void {
if (this.restorationState === 'complete' || this.restorationState === 'skipped') return;
throw new Error(`Tab layout restoration is ${this.restorationState}; destructive deletion is unavailable`);
}
/** Repair/migrate every owner visible after startup restoration. */
async reconcileAfterRestoration(): Promise<void> {
if (this.restorationState !== 'complete') return;
const { persisted, live } = this.sessionRecords();
const webviews = await this.deps.readWebviews();
const owners = new Set<string>();
for (const record of [...persisted, ...live, ...webviews]) owners.add(ownerOf(record));
for (const owner of owners) await this.get(owner);
}
private sessionRecords(): { persisted: TabLayoutSessionRecord[]; live: TabLayoutSessionRecord[] } {
const persisted = Object.entries(this.deps.store.getSessions()).map(([id, record]) => ({
id,
owner: record.owner,
createdAt: record.createdAt,
parentSessionId: record.parentSessionId,
}));
const live = [...this.deps.sessions.values()].map((record) => ({
id: record.id,
owner: record.owner,
createdAt: record.createdAt,
parentSessionId: record.parentSessionId,
}));
return { persisted, live };
}
private async facts(owner: string): Promise<{
persisted: TabLayoutSessionRecord[];
live: TabLayoutSessionRecord[];
webviews: readonly TabLayoutWebviewRecord[];
metadata: TabRefMetadata[];
}> {
const { persisted, live } = this.sessionRecords();
const webviews = await this.deps.readWebviews();
const sessions = new Map<string, TabLayoutSessionRecord>();
for (const record of persisted) sessions.set(record.id, record);
for (const record of live) sessions.set(record.id, record);
const ownedSessions = [...sessions.values()]
.filter((record) => ownerOf(record) === owner)
.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
const sessionOrder = new Map(ownedSessions.map((record, index) => [record.id, index]));
const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({
kind: 'session',
id: record.id,
ownerValid: ownerOf(record) === owner,
visible: true,
order: sessionOrder.get(record.id) ?? record.createdAt,
parentSessionId: record.parentSessionId,
}));
const offset = ownedSessions.length;
webviews.forEach((record, index) =>
metadata.push({
kind: 'webview',
id: record.id,
ownerValid: ownerOf(record) === owner,
visible: true,
order: offset + index,
})
);
return { persisted, live, webviews, metadata };
}
private prepareCommit(base: TabLayout, next: TabLayout): TabLayout {
return validateTabLayout({
...next,
version: base.version + 1,
updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(),
});
}
private prepareOrderProjection(item: OwnerProjectionPublication): PreparedOrderProjection {
const excluded = item.excludedSessionIds ?? new Set<string>();
const authoritativeBeforeIds = item.metadata
.filter((fact) => fact.kind === 'session' && fact.ownerValid && fact.visible)
.map((fact) => fact.id);
const facts = authoritativeBeforeIds.filter((id) => !excluded.has(id));
const visible = new Set(facts);
const rawPrevious = item.previous ? flattenOwnerSessionOrder(item.previous) : [];
const rawNext = flattenOwnerSessionOrder(item.next);
const previousOrder = rawPrevious.filter((id) => visible.has(id) || excluded.has(id));
const order = rawNext.filter((id) => visible.has(id) && !excluded.has(id));
const excludedIds = normalizeSessionOrder([...excluded]);
return {
owner: item.owner,
previousOrder,
authoritativeBeforeIds: normalizeSessionOrder([...authoritativeBeforeIds, ...excluded]),
excludedIds,
currentIds: normalizeSessionOrder([...order, ...facts]),
order,
};
}
private projectOrder(
latest: readonly string[],
projections: readonly PreparedOrderProjection[],
preferred?: readonly string[]
): string[] {
const before = normalizeSessionOrder(latest);
const removed = new Set(
projections.flatMap((projection) => projection.excludedIds.filter((id) => !projection.currentIds.includes(id)))
);
return recomposeGlobalSessionOrder(
before.filter((id) => !removed.has(id)),
projections.map((projection) => ({
owner: projection.owner,
ownedIds: projection.currentIds,
order: projection.order,
})),
preferred
);
}
private publish(
layouts: Readonly<Record<string, TabLayout>>,
publications: readonly OwnerProjectionPublication[],
preferred?: readonly string[]
): SessionOrderProjectionChange {
const projections = publications.map((item) => this.prepareOrderProjection(item));
let beforeOrder: string[] = [];
const accepted = this.deps.store.commitTabLayoutProjection(layouts, (latest) => {
beforeOrder = normalizeSessionOrder(latest);
return this.projectOrder(beforeOrder, projections, preferred);
});
const changedEntries: Array<[string, string[]]> = [];
for (const projection of projections) {
const beforeIds = new Set(projection.authoritativeBeforeIds);
const currentIds = new Set(projection.currentIds);
const persistedBefore = beforeOrder.filter((id) => beforeIds.has(id));
const persistedAfter = accepted.sessionOrder.filter((id) => currentIds.has(id));
const layoutOrderChanged = !sameOrder(projection.previousOrder, projection.order);
const persistedOwnerSliceChanged = !sameOrder(persistedBefore, persistedAfter);
if (layoutOrderChanged || persistedOwnerSliceChanged) {
changedEntries.push([projection.owner, persistedAfter]);
}
}
const change: SessionOrderProjectionChange = {
changedOwnerOrders: Object.fromEntries(changedEntries),
globalOrder: [...accepted.sessionOrder],
globalChanged: !sameOrder(beforeOrder, accepted.sessionOrder),
};
for (const [owner, layout] of Object.entries(accepted.layouts)) {
this.deps.broadcast(SseEvent.TabLayoutChanged, { owner, version: layout.version });
}
if (changedEntries.length > 0 || change.globalChanged) this.deps.broadcastSessionOrder(change);
return change;
}
private commit(
owner: string,
base: TabLayout,
next: TabLayout,
metadata: readonly TabRefMetadata[],
previous: TabLayout | null = base.version < 0 ? null : base
): TabLayout {
const stored = this.prepareCommit(base, next);
this.publish({ [owner]: stored }, [{ owner, previous, next: stored, metadata }]);
return stored;
}
private async prepareUnlocked(owner: string): Promise<PreparedOwnerLayout> {
const facts = await this.facts(owner);
const current = this.deps.store.getTabLayout(owner);
const authoritative = normalizeOrMigrateOwnerTabLayout({
owner,
layouts: current ? { [owner]: current } : undefined,
sessionOrder: this.deps.store.getSessionOrder(),
persistedSessions: facts.persisted,
liveSessions: facts.live,
webviews: facts.webviews,
updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(),
}).layout;
return {
current,
authoritative,
metadata: facts.metadata,
needsReconciliationCommit: !current || !sameLayout(current, authoritative),
};
}
private async getUnlocked(owner: string): Promise<TabLayout> {
const prepared = await this.prepareUnlocked(owner);
if (!prepared.needsReconciliationCommit) {
const publication = {
owner,
previous: prepared.current,
next: prepared.authoritative,
metadata: prepared.metadata,
};
const latest = this.deps.store.getSessionOrder();
const projected = this.projectOrder(latest, [this.prepareOrderProjection(publication)]);
if (!sameOrder(normalizeSessionOrder(latest), projected)) this.publish({}, [publication]);
return prepared.authoritative;
}
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
return this.commit(owner, base, prepared.authoritative, prepared.metadata);
}
async get(owner: string): Promise<TabLayout> {
return this.withOwner(owner, () => this.getUnlocked(owner));
}
async put(owner: string, desired: unknown, baseVersion: number): Promise<TabLayoutPutResult> {
return this.withOwner(owner, async () => {
const prepared = await this.prepareUnlocked(owner);
if (baseVersion !== prepared.authoritative.version) return { status: 'conflict', layout: prepared.authoritative };
const validated = validateTabLayout(desired);
const owned = new Set(prepared.metadata.filter((item) => item.ownerValid && item.visible).map(refKey));
const refs = [...validated.groups.flatMap((group) => group.refs), ...validated.ungrouped];
const invalid = refs.find((ref) => !owned.has(refKey(ref)));
if (invalid)
throw new TabLayoutValidationError(`ref is not owned by layout owner: ${invalid.kind}:${invalid.id}`);
const normalized = normalizeTabLayout(
{ ...validated, version: prepared.authoritative.version },
prepared.metadata
);
return {
status: 'updated',
layout: this.commit(owner, prepared.authoritative, normalized, prepared.metadata, prepared.current),
};
});
}
async putLegacyOrder(actor: LegacyOrderActor, requested: readonly string[]): Promise<LegacyOrderPutResult> {
return actor.isAdmin ? this.putAdminLegacyOrder(requested) : this.putOwnerLegacyOrder(actor.owner, requested);
}
private async putOwnerLegacyOrder(owner: string, requested: readonly string[]): Promise<LegacyOrderPutResult> {
return this.withOwner(owner, async () => {
const prepared = await this.prepareUnlocked(owner);
const normalized = normalizeSessionOrder(requested);
const visible = new Set(
prepared.metadata
.filter((item) => item.kind === 'session' && item.ownerValid && item.visible)
.map((item) => item.id)
);
// Unknown or foreign ids are DROPPED, never a 400: the browser debounces
// its reorder push (and swallows errors), so a session deleted inside
// that window would otherwise cost the user the whole reorder — and the
// endpoint sits on the stable /api/v1 surface, where the pre-layout
// server merged leniently. Same philosophy as resolveParentSessionId.
const requestedVisible = normalized.filter((id) => visible.has(id));
const currentKnown = flattenOwnerSessionOrder(prepared.authoritative).filter((id) => visible.has(id));
const effective = mergeSessionOrder(requestedVisible, currentKnown);
const ranked = applyLegacySessionRank(prepared.authoritative, effective, prepared.metadata);
const needsLayout = prepared.needsReconciliationCommit || !sameLayout(prepared.authoritative, ranked);
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
const next = needsLayout ? this.prepareCommit(base, ranked) : prepared.authoritative;
const change = this.publish(needsLayout ? { [owner]: next } : {}, [
{ owner, previous: prepared.current, next, metadata: prepared.metadata },
]);
return { order: flattenOwnerSessionOrder(next).filter((id) => visible.has(id)), ...change };
});
}
private async putAdminLegacyOrder(requested: readonly string[]): Promise<LegacyOrderPutResult> {
const discoverOwners = (): string[] => {
const owners = new Set(Object.keys(this.deps.store.getTabLayouts()));
const { persisted, live } = this.sessionRecords();
for (const record of [...persisted, ...live]) owners.add(ownerOf(record));
return [...owners].sort();
};
for (;;) {
const owners = discoverOwners();
const result = await this.withOwners(owners, async (): Promise<LegacyOrderPutResult | null> => {
if (!sameOrder(owners, discoverOwners())) return null;
const normalized = normalizeSessionOrder(requested);
const knownOwners = new Map<string, string>();
const { persisted, live } = this.sessionRecords();
for (const record of persisted) knownOwners.set(record.id, ownerOf(record));
for (const record of live) knownOwners.set(record.id, ownerOf(record));
// Unknown ids are DROPPED, never a 400 — see putOwnerLegacyOrder. In
// single-user mode every request is the synthetic admin, so this path
// IS the one the browser's debounced (error-swallowing) push hits.
const known = normalized.filter((id) => knownOwners.has(id));
const publications: OwnerProjectionPublication[] = [];
const updates: Record<string, TabLayout> = Object.create(null) as Record<string, TabLayout>;
for (const owner of owners) {
const prepared = await this.prepareUnlocked(owner);
const visible = new Set(
prepared.metadata
.filter((item) => item.kind === 'session' && item.ownerValid && item.visible)
.map((item) => item.id)
);
const requestedOwner = known.filter((id) => visible.has(id));
const currentKnown = flattenOwnerSessionOrder(prepared.authoritative).filter((id) => visible.has(id));
const effective = mergeSessionOrder(requestedOwner, currentKnown);
const ranked = applyLegacySessionRank(prepared.authoritative, effective, prepared.metadata);
const needsLayout = prepared.needsReconciliationCommit || !sameLayout(prepared.authoritative, ranked);
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
const next = needsLayout ? this.prepareCommit(base, ranked) : prepared.authoritative;
if (needsLayout) updates[owner] = next;
publications.push({ owner, previous: prepared.current, next, metadata: prepared.metadata });
}
const change = this.publish(updates, publications, known);
return { order: [...change.globalOrder], ...change };
});
if (result) return result;
}
}
/** Reconcile one completed session creation into one versioned mutation. */
async sessionCreated(owner: string): Promise<TabLayout> {
return this.get(owner);
}
/** Reconcile one completed saved-webview creation into one versioned mutation. */
async webviewCreated(owner: string): Promise<TabLayout> {
return this.get(owner);
}
async sessionsRemoved(removed: readonly RemovedTabLayoutSession[]): Promise<void> {
if (this.restorationState !== 'complete' || removed.length === 0) return;
const byOwner = new Map<string, string[]>();
for (const item of removed) {
const owner = ownerOf(item);
const ids = byOwner.get(owner) ?? [];
ids.push(item.id);
byOwner.set(owner, ids);
}
const owners = [...byOwner.keys()].sort();
await this.withOwners(owners, async () => {
const publications: OwnerProjectionPublication[] = [];
const updates: Record<string, TabLayout> = Object.create(null) as Record<string, TabLayout>;
for (const owner of owners) {
const ids = byOwner.get(owner) ?? [];
const prepared = await this.prepareUnlocked(owner);
const current = prepared.current;
// Normalize and prune together so stale cleanup, orphan materialization,
// and missing-ref repair remain one versioned server mutation.
const next = normalizeTabLayout(
materializeOrphans(prepared.authoritative, ids, prepared.metadata),
prepared.metadata
);
const stored = current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null;
if (stored) updates[owner] = stored;
publications.push({
owner,
previous: current,
next: stored ?? next,
metadata: prepared.metadata,
excludedSessionIds: new Set(ids),
});
}
if (publications.length > 0) this.publish(updates, publications);
});
}
/**
* Hold the owner mutation lock across an irreversible session deletion.
* All failure-prone normalization happens before `action`; the prepared layout
* commits only after the resource cleanup finishes.
*/
async runSessionDeletion<T>(removed: readonly RemovedTabLayoutSession[], action: () => Promise<T>): Promise<T> {
// A failed restoration must not lock the user out of explicitly closing a
// tab for the rest of the process lifetime: degrade to best-effort deletion
// without layout coordination. Only the AUTOMATED stale sweep stays
// fail-closed on 'failed' (runStaleSessionCleanup), because that one picks
// its victims itself from state a failed restore may have left incomplete.
if (this.restorationState === 'failed') return action();
this.assertDeletionReady();
if (this.restorationState === 'skipped' || removed.length === 0) return action();
const owners = new Set(removed.map(ownerOf));
if (owners.size !== 1) throw new Error('A session deletion transaction must contain exactly one owner');
const owner = owners.values().next().value as string;
const ids = removed.map((item) => item.id);
return this.withOwner(owner, async () => {
const prepared = await this.prepareUnlocked(owner);
const current = prepared.current;
// Prepare while the soon-to-be-deleted sessions are still known, so
// direct children can be materialized before their parent ref is removed.
const next = materializeOrphans(prepared.authoritative, ids, prepared.metadata);
const stored = current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null;
const result = await action();
this.publish(stored ? { [owner]: stored } : {}, [
{
owner,
previous: current,
next: stored ?? next,
metadata: prepared.metadata,
excludedSessionIds: new Set(ids),
},
]);
return result;
});
}
/**
* Prepare every affected owner layout before bulk stale-state deletion.
* The StateStore action remains synchronous in production, so the candidate
* snapshot cannot change between successful preparation and resource removal.
*/
async runStaleSessionCleanup<T>(
activeSessionIds: ReadonlySet<string>,
action: (ids: ReadonlySet<string>) => T | Promise<T>
): Promise<T> {
this.assertDeletionReady();
const candidates = Object.entries(this.deps.store.getSessions())
.filter(([id, record]) => !activeSessionIds.has(id) && record.pinned !== true)
.map(([id, record]) => ({ id, owner: record.owner }));
if (this.restorationState === 'skipped') return action(new Set(candidates.map((item) => item.id)));
if (candidates.length === 0) return action(new Set());
const byOwner = new Map<string, string[]>();
for (const item of candidates) {
const owner = ownerOf(item);
const ids = byOwner.get(owner) ?? [];
ids.push(item.id);
byOwner.set(owner, ids);
}
const owners = [...byOwner.keys()].sort();
return this.withOwners(owners, async () => {
const webviews = await this.deps.readWebviews();
const persistedState = this.deps.store.getSessions();
const persisted = Object.entries(persistedState).map(([id, record]) => ({
id,
owner: record.owner,
createdAt: record.createdAt,
parentSessionId: record.parentSessionId,
}));
const liveIds = new Set(this.deps.sessions.keys());
const confirmed = candidates.filter((candidate) => {
const record = persistedState[candidate.id];
return (
record !== undefined &&
ownerOf(record) === ownerOf(candidate) &&
record.pinned !== true &&
!activeSessionIds.has(candidate.id) &&
!liveIds.has(candidate.id)
);
});
const confirmedByOwner = new Map<string, string[]>();
for (const item of confirmed) {
const owner = ownerOf(item);
const ids = confirmedByOwner.get(owner) ?? [];
ids.push(item.id);
confirmedByOwner.set(owner, ids);
}
const prepared: Array<{
owner: string;
current: TabLayout | null;
next: TabLayout;
stored: TabLayout | null;
metadata: TabRefMetadata[];
excludedSessionIds: ReadonlySet<string>;
}> = [];
for (const owner of owners) {
const ids = confirmedByOwner.get(owner) ?? [];
if (ids.length === 0) continue;
const current = this.deps.store.getTabLayout(owner);
const sessions = new Map<string, TabLayoutSessionRecord>();
for (const record of persisted) sessions.set(record.id, record);
for (const record of this.deps.sessions.values()) sessions.set(record.id, record);
const ownedSessions = [...sessions.values()]
.filter((record) => ownerOf(record) === owner)
.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
const sessionOrder = new Map(ownedSessions.map((record, index) => [record.id, index]));
const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({
kind: 'session',
id: record.id,
ownerValid: ownerOf(record) === owner,
visible: true,
order: sessionOrder.get(record.id) ?? record.createdAt,
parentSessionId: record.parentSessionId,
}));
const offset = ownedSessions.length;
webviews.forEach((record, index) =>
metadata.push({
kind: 'webview',
id: record.id,
ownerValid: ownerOf(record) === owner,
visible: true,
order: offset + index,
})
);
const authoritative = normalizeOrMigrateOwnerTabLayout({
owner,
layouts: current ? { [owner]: current } : undefined,
sessionOrder: this.deps.store.getSessionOrder(),
persistedSessions: persisted,
liveSessions: [...this.deps.sessions.values()],
webviews,
updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(),
}).layout;
const next = materializeOrphans(authoritative, ids, metadata);
prepared.push({
owner,
current,
next,
stored: current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null,
metadata,
excludedSessionIds: new Set(ids),
});
}
const result = await action(new Set(confirmed.map((item) => item.id)));
if (prepared.length > 0) {
this.publish(
Object.fromEntries(prepared.filter((item) => item.stored).map((item) => [item.owner, item.stored!])),
prepared.map((item) => ({
owner: item.owner,
previous: item.current,
next: item.stored ?? item.next,
metadata: item.metadata,
excludedSessionIds: item.excludedSessionIds,
}))
);
}
return result;
});
}
async webviewDeleted(owner: string, id: string): Promise<void> {
// Same explicit-user-action escape hatch as runSessionDeletion: a failed
// restore skips layout coordination instead of failing the delete.
if (this.restorationState === 'failed') return;
this.assertDeletionReady();
if (this.restorationState === 'skipped') return;
await this.withOwner(owner, async () => {
const current = this.deps.store.getTabLayout(owner);
if (!current) return;
const strip = (refs: readonly TabRef[]): TabRef[] =>
refs.filter((ref) => ref.kind !== 'webview' || ref.id !== id).map((ref) => ({ ...ref }));
const stripped: TabLayout = {
...current,
groups: current.groups.map((group) => ({ ...group, refs: strip(group.refs) })),
ungrouped: strip(current.ungrouped),
};
const { metadata } = await this.facts(owner);
const next = normalizeTabLayout(stripped, metadata);
if (!sameLayout(current, next)) this.commit(owner, current, next, metadata);
});
}
}
+547
View File
@@ -0,0 +1,547 @@
/**
* @fileoverview Framework-independent tab layout model.
*
* Callers provide owner-scoped session/webview metadata. This module deliberately
* has no dependency on session runtime, persistence, routes, or browser state.
*/
export const MAX_TAB_GROUPS = 32;
export const MAX_TAB_GROUP_NAME_LENGTH = 60;
export const MAX_TAB_REFS = 512;
export type TabRefKind = 'session' | 'webview';
export interface TabRef {
kind: TabRefKind;
id: string;
placement?: 'manual';
}
export interface TabGroup {
id: string;
name: string;
refs: TabRef[];
}
export interface TabLayout {
version: number;
groups: TabGroup[];
ungrouped: TabRef[];
updatedAt: string;
}
/** Owner and lineage facts supplied by the server or browser integration. */
export interface TabRefMetadata {
kind: TabRefKind;
id: string;
/** False for missing, foreign-owned, or otherwise invalid refs. */
ownerValid: boolean;
/** False when the owner is not permitted to see/store this ref. */
visible: boolean;
/** Stable creation/sibling order. Ties fall back to kind and id. */
order: number;
/** Session-only lineage hint. Ignored for webviews. */
parentSessionId?: string;
}
export interface TabMoveTarget {
/** Null denotes the real ungrouped container. */
groupId: string | null;
/** Zero-based insertion index after removing the moved block. */
index: number;
}
export interface CreateTabGroupInput {
id: string;
name: string;
index?: number;
}
export interface VisibleTabProjectionOptions {
liveSessionIds: ReadonlySet<string>;
openWebviewIds: ReadonlySet<string>;
collapsedGroupIds?: ReadonlySet<string>;
highlighted?: TabRef;
}
export class TabLayoutValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'TabLayoutValidationError';
}
}
const keyOf = (ref: Pick<TabRef, 'kind' | 'id'>): string => `${ref.kind}\u0000${ref.id}`;
function assertRecord(value: unknown, label: string): asserts value is Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new TabLayoutValidationError(`${label} must be an object`);
}
}
function parseNonEmptyString(value: unknown, label: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new TabLayoutValidationError(`${label} must be a non-empty string`);
}
return value;
}
function parseName(value: unknown, label: string): string {
if (typeof value !== 'string') throw new TabLayoutValidationError(`${label} must be a string`);
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.length > MAX_TAB_GROUP_NAME_LENGTH) {
throw new TabLayoutValidationError(`${label} must be 1-${MAX_TAB_GROUP_NAME_LENGTH} trimmed characters`);
}
return trimmed;
}
function parseRef(value: unknown, label: string): TabRef {
assertRecord(value, label);
if (value.kind !== 'session' && value.kind !== 'webview') {
throw new TabLayoutValidationError(`${label}.kind must be session or webview`);
}
const id = parseNonEmptyString(value.id, `${label}.id`);
if (value.placement !== undefined && value.placement !== 'manual') {
throw new TabLayoutValidationError(`${label}.placement must be manual when present`);
}
return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id };
}
function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout {
assertRecord(input, 'layout');
if (!Number.isSafeInteger(input.version) || (input.version as number) < 0) {
throw new TabLayoutValidationError('layout.version must be a non-negative safe integer');
}
if (!Array.isArray(input.groups)) throw new TabLayoutValidationError('layout.groups must be an array');
if (input.groups.length > MAX_TAB_GROUPS) {
throw new TabLayoutValidationError(`layout.groups cannot exceed ${MAX_TAB_GROUPS}`);
}
if (!Array.isArray(input.ungrouped)) throw new TabLayoutValidationError('layout.ungrouped must be an array');
const updatedAt = parseNonEmptyString(input.updatedAt, 'layout.updatedAt');
const groupIds = new Set<string>();
const refKeys = new Set<string>();
let refCount = input.ungrouped.length;
const parseStoredRef = (entry: unknown, label: string): TabRef => {
const ref = parseRef(entry, label);
const key = keyOf(ref);
if (!repairDuplicates && refKeys.has(key)) {
throw new TabLayoutValidationError(`duplicate ref: ${ref.kind}:${ref.id}`);
}
refKeys.add(key);
return ref;
};
const groups = input.groups.map((rawGroup, groupIndex): TabGroup => {
const label = `layout.groups[${groupIndex}]`;
assertRecord(rawGroup, label);
const id = parseNonEmptyString(rawGroup.id, `${label}.id`);
if (groupIds.has(id)) throw new TabLayoutValidationError(`duplicate group id: ${id}`);
groupIds.add(id);
if (!Array.isArray(rawGroup.refs)) throw new TabLayoutValidationError(`${label}.refs must be an array`);
refCount += rawGroup.refs.length;
return {
id,
name: parseName(rawGroup.name, `${label}.name`),
refs: rawGroup.refs.map((entry, refIndex) => parseStoredRef(entry, `${label}.refs[${refIndex}]`)),
};
});
if (refCount > MAX_TAB_REFS) {
throw new TabLayoutValidationError(`layout cannot contain more than ${MAX_TAB_REFS} refs`);
}
return {
version: input.version as number,
groups,
ungrouped: input.ungrouped.map((entry, index) => parseStoredRef(entry, `layout.ungrouped[${index}]`)),
updatedAt,
};
}
/** Validate and defensively clone a layout. Group names are normalized by trimming. */
export function validateTabLayout(input: unknown): TabLayout {
return parseTabLayout(input, false);
}
function validMetadata(metadata: readonly TabRefMetadata[]): TabRefMetadata[] {
const byKey = new Map<string, TabRefMetadata>();
for (const item of metadata) {
if ((item.kind !== 'session' && item.kind !== 'webview') || typeof item.id !== 'string' || item.id.length === 0) {
throw new TabLayoutValidationError('metadata contains an invalid ref identity');
}
if (!Number.isFinite(item.order)) throw new TabLayoutValidationError(`metadata order is invalid for ${item.id}`);
if (!item.ownerValid || !item.visible) continue;
const key = keyOf(item);
if (!byKey.has(key)) byKey.set(key, { ...item });
}
const compareText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
const result = [...byKey.values()].sort(
(a, b) => a.order - b.order || compareText(a.kind, b.kind) || compareText(a.id, b.id)
);
if (result.length > MAX_TAB_REFS) {
throw new TabLayoutValidationError(`owner layout cannot exceed ${MAX_TAB_REFS} refs`);
}
return result;
}
interface LocatedRef {
ref: TabRef;
container: string | null;
position: number;
}
function locations(layout: TabLayout): LocatedRef[] {
const result: LocatedRef[] = [];
let position = 0;
for (const group of layout.groups) {
for (const ref of group.refs) result.push({ ref, container: group.id, position: position++ });
}
for (const ref of layout.ungrouped) result.push({ ref, container: null, position: position++ });
return result;
}
function withContainers(layout: TabLayout, refsByContainer: ReadonlyMap<string | null, TabRef[]>): TabLayout {
return {
...layout,
groups: layout.groups.map((group) => ({ ...group, refs: [...(refsByContainer.get(group.id) ?? [])] })),
ungrouped: [...(refsByContainer.get(null) ?? [])],
};
}
/**
* Reconcile a layout against owner-valid metadata and session lineage.
* First stored occurrence wins; missing valid refs append to ungrouped.
*/
export function normalizeTabLayout(input: TabLayout, metadata: readonly TabRefMetadata[]): TabLayout {
const layout = parseTabLayout(input, true);
const valid = validMetadata(metadata);
const metadataByKey = new Map(valid.map((item) => [keyOf(item), item]));
const knownMetadataKeys = new Set(metadata.map((item) => keyOf(item)));
const seen = new Set<string>();
const dedupedByContainer = new Map<string | null, TabRef[]>();
for (const group of layout.groups) dedupedByContainer.set(group.id, []);
dedupedByContainer.set(null, []);
for (const located of locations(layout)) {
const key = keyOf(located.ref);
// Missing metadata is unknown rather than invalid (for example, during
// restoration). Preserve it until an explicit invalid/deletion fact arrives.
if ((knownMetadataKeys.has(key) && !metadataByKey.has(key)) || seen.has(key)) continue;
seen.add(key);
dedupedByContainer.get(located.container)!.push({ ...located.ref });
}
for (const item of valid) {
const key = keyOf(item);
if (seen.has(key)) continue;
seen.add(key);
dedupedByContainer.get(null)!.push({ kind: item.kind, id: item.id });
}
if (seen.size > MAX_TAB_REFS) {
throw new TabLayoutValidationError(`normalized layout cannot exceed ${MAX_TAB_REFS} refs`);
}
let working = withContainers(layout, dedupedByContainer);
const located = locations(working);
const refByKey = new Map(located.map((item) => [keyOf(item.ref), item.ref]));
const sessionById = new Map(valid.filter((item) => item.kind === 'session').map((item) => [item.id, item]));
const manualCycleEdges = new Set<string>();
const state = new Map<string, 'visiting' | 'done'>();
const visit = (id: string): void => {
if (state.get(id) === 'done') return;
state.set(id, 'visiting');
const item = sessionById.get(id);
const stored = refByKey.get(keyOf({ kind: 'session', id }));
if (item?.parentSessionId && stored?.placement !== 'manual') {
const parent = sessionById.get(item.parentSessionId);
const parentStored = refByKey.get(keyOf({ kind: 'session', id: item.parentSessionId }));
if (parent && parentStored) {
if (state.get(parent.id) === 'visiting') manualCycleEdges.add(id);
else visit(parent.id);
}
}
state.set(id, 'done');
};
for (const item of located)
if (item.ref.kind === 'session' && state.get(item.ref.id) === undefined) visit(item.ref.id);
if (manualCycleEdges.size > 0) {
working = {
...working,
groups: working.groups.map((group) => ({
...group,
refs: group.refs.map((ref) =>
ref.kind === 'session' && manualCycleEdges.has(ref.id) ? { ...ref, placement: 'manual' } : ref
),
})),
ungrouped: working.ungrouped.map((ref) =>
ref.kind === 'session' && manualCycleEdges.has(ref.id) ? { ...ref, placement: 'manual' } : ref
),
};
}
const ordered = locations(working);
const updatedRefByKey = new Map(ordered.map((item) => [keyOf(item.ref), item.ref]));
const parentOf = new Map<string, string>();
const children = new Map<string, string[]>();
for (const item of ordered) {
if (item.ref.kind !== 'session' || item.ref.placement === 'manual') continue;
const info = sessionById.get(item.ref.id);
const parentId = info?.parentSessionId;
if (!parentId || !sessionById.has(parentId) || !updatedRefByKey.has(keyOf({ kind: 'session', id: parentId })))
continue;
parentOf.set(item.ref.id, parentId);
const siblings = children.get(parentId) ?? [];
siblings.push(item.ref.id);
children.set(parentId, siblings);
}
const emitted = new Set<string>();
const output = new Map<string | null, TabRef[]>();
for (const group of working.groups) output.set(group.id, []);
output.set(null, []);
const emitSubtree = (root: TabRef, container: string | null): void => {
const rootKey = keyOf(root);
if (emitted.has(rootKey)) return;
emitted.add(rootKey);
output.get(container)!.push({ ...root });
if (root.kind !== 'session') return;
for (const childId of children.get(root.id) ?? []) {
const child = updatedRefByKey.get(keyOf({ kind: 'session', id: childId }));
if (child) emitSubtree(child, container);
}
};
for (const item of ordered) {
if (item.ref.kind === 'session' && parentOf.has(item.ref.id)) continue;
emitSubtree(item.ref, item.container);
}
return withContainers(working, output);
}
function cloneForEdit(input: TabLayout): TabLayout {
return validateTabLayout(input);
}
function boundedIndex(index: number, length: number, label: string): number {
if (!Number.isSafeInteger(index) || index < 0 || index > length) {
throw new TabLayoutValidationError(`${label} index must be between 0 and ${length}`);
}
return index;
}
export function createGroup(input: TabLayout, group: CreateTabGroupInput): TabLayout {
const layout = cloneForEdit(input);
if (layout.groups.length >= MAX_TAB_GROUPS)
throw new TabLayoutValidationError(`cannot exceed ${MAX_TAB_GROUPS} groups`);
const id = parseNonEmptyString(group.id, 'group.id');
if (layout.groups.some((entry) => entry.id === id)) throw new TabLayoutValidationError(`duplicate group id: ${id}`);
const index = boundedIndex(group.index ?? layout.groups.length, layout.groups.length, 'group');
const groups = [...layout.groups];
groups.splice(index, 0, { id, name: parseName(group.name, 'group.name'), refs: [] });
return { ...layout, groups };
}
export function renameGroup(input: TabLayout, groupId: string, name: string): TabLayout {
const layout = cloneForEdit(input);
if (!layout.groups.some((group) => group.id === groupId))
throw new TabLayoutValidationError(`unknown group: ${groupId}`);
return {
...layout,
groups: layout.groups.map((group) =>
group.id === groupId ? { ...group, name: parseName(name, 'group.name') } : group
),
};
}
export function deleteGroup(input: TabLayout, groupId: string): TabLayout {
const layout = cloneForEdit(input);
const group = layout.groups.find((entry) => entry.id === groupId);
if (!group) throw new TabLayoutValidationError(`unknown group: ${groupId}`);
return {
...layout,
groups: layout.groups.filter((entry) => entry.id !== groupId),
ungrouped: [...layout.ungrouped, ...group.refs.map((ref) => ({ ...ref }))],
};
}
export function reorderGroup(input: TabLayout, groupId: string, index: number): TabLayout {
const layout = cloneForEdit(input);
const from = layout.groups.findIndex((group) => group.id === groupId);
if (from < 0) throw new TabLayoutValidationError(`unknown group: ${groupId}`);
const groups = [...layout.groups];
const [group] = groups.splice(from, 1);
groups.splice(boundedIndex(index, groups.length, 'group'), 0, group);
return { ...layout, groups };
}
function mapRef(input: TabLayout, target: TabRef, transform: (ref: TabRef) => TabRef): TabLayout {
const layout = cloneForEdit(input);
let found = false;
const apply = (ref: TabRef): TabRef => {
if (keyOf(ref) !== keyOf(target)) return ref;
found = true;
return transform(ref);
};
const result = {
...layout,
groups: layout.groups.map((group) => ({ ...group, refs: group.refs.map(apply) })),
ungrouped: layout.ungrouped.map(apply),
};
if (!found) throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`);
return result;
}
export function setManualPlacement(input: TabLayout, target: TabRef, manual: boolean): TabLayout {
if (!manual) {
throw new TabLayoutValidationError('manual placement can only be cleared through followParent');
}
return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' }));
}
export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout {
const normalized = normalizeTabLayout(input, metadata);
if (target.kind !== 'session') {
throw new TabLayoutValidationError('only a session ref can follow a parent');
}
const valid = validMetadata(metadata);
const targetMetadata = valid.find((item) => item.kind === 'session' && item.id === target.id);
if (!targetMetadata?.parentSessionId) {
throw new TabLayoutValidationError(`session has no owner-valid parent: ${target.id}`);
}
const parentMetadata = valid.find((item) => item.kind === 'session' && item.id === targetMetadata.parentSessionId);
if (!parentMetadata) {
throw new TabLayoutValidationError(`session parent is not owner-valid: ${targetMetadata.parentSessionId}`);
}
const storedKeys = new Set(locations(normalized).map((item) => keyOf(item.ref)));
if (!storedKeys.has(keyOf(target))) {
throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`);
}
const parentRef: TabRef = { kind: 'session', id: targetMetadata.parentSessionId };
if (!storedKeys.has(keyOf(parentRef))) {
throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`);
}
const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id }));
return normalizeTabLayout(cleared, metadata);
}
function descendantKeys(root: TabRef, layout: TabLayout, metadata: readonly TabRefMetadata[]): Set<string> {
const valid = validMetadata(metadata);
const stored = new Map(locations(layout).map((item) => [keyOf(item.ref), item.ref]));
const children = new Map<string, string[]>();
for (const item of valid) {
if (item.kind !== 'session' || !item.parentSessionId) continue;
const child = stored.get(keyOf(item));
if (!child || child.placement === 'manual' || !stored.has(keyOf({ kind: 'session', id: item.parentSessionId })))
continue;
const siblings = children.get(item.parentSessionId) ?? [];
siblings.push(item.id);
children.set(item.parentSessionId, siblings);
}
const result = new Set<string>();
const add = (ref: TabRef): void => {
const key = keyOf(ref);
if (result.has(key)) return;
result.add(key);
if (ref.kind !== 'session') return;
for (const childId of children.get(ref.id) ?? []) add({ kind: 'session', id: childId });
};
add(root);
return result;
}
export function moveRef(
input: TabLayout,
target: TabRef,
destination: TabMoveTarget,
metadata: readonly TabRefMetadata[]
): TabLayout {
let layout = normalizeTabLayout(input, metadata);
const targetKey = keyOf(target);
if (!locations(layout).some((item) => keyOf(item.ref) === targetKey)) {
throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`);
}
if (destination.groupId !== null && !layout.groups.some((group) => group.id === destination.groupId)) {
throw new TabLayoutValidationError(`unknown group: ${destination.groupId}`);
}
const blockKeys = descendantKeys(target, layout, metadata);
const block = locations(layout)
.filter((item) => blockKeys.has(keyOf(item.ref)))
.map((item) => ({ ...item.ref }));
const metadataItem = validMetadata(metadata).find((item) => keyOf(item) === targetKey);
if (target.kind === 'session' && metadataItem?.parentSessionId) block[0] = { ...block[0], placement: 'manual' };
const remaining = new Map<string | null, TabRef[]>();
for (const group of layout.groups)
remaining.set(
group.id,
group.refs.filter((ref) => !blockKeys.has(keyOf(ref)))
);
remaining.set(
null,
layout.ungrouped.filter((ref) => !blockKeys.has(keyOf(ref)))
);
const destinationRefs = remaining.get(destination.groupId)!;
const index = boundedIndex(destination.index, destinationRefs.length, 'destination');
destinationRefs.splice(index, 0, ...block);
layout = withContainers(layout, remaining);
return normalizeTabLayout(layout, metadata);
}
/**
* Remove explicitly deleted session parents and pin their direct inherited
* children at their current stored positions so a later reused ID cannot adopt them.
*/
export function materializeOrphans(
input: TabLayout,
removedParentIds: readonly string[],
metadata: readonly TabRefMetadata[]
): TabLayout {
const layout = cloneForEdit(input);
const removed = new Set(removedParentIds);
const directChildren = new Set(
validMetadata(metadata)
.filter((item) => item.kind === 'session' && item.parentSessionId && removed.has(item.parentSessionId))
.map((item) => item.id)
);
const transform = (refs: readonly TabRef[]): TabRef[] =>
refs
.filter((ref) => ref.kind !== 'session' || !removed.has(ref.id))
.map((ref) =>
ref.kind === 'session' && directChildren.has(ref.id) && ref.placement !== 'manual'
? { ...ref, placement: 'manual' }
: { ...ref }
);
return {
...layout,
groups: layout.groups.map((group) => ({ ...group, refs: transform(group.refs) })),
ungrouped: transform(layout.ungrouped),
};
}
/** Session-only compatibility order; collapse and webviews do not affect it. */
export function flattenOwnerSessionOrder(input: TabLayout): string[] {
return locations(validateTabLayout(input))
.map((item) => item.ref)
.filter((ref): ref is TabRef & { kind: 'session' } => ref.kind === 'session')
.map((ref) => ref.id);
}
/** Locally renderable order used by tab painting and Alt-number consumers. */
export function flattenVisibleRefs(input: TabLayout, options: VisibleTabProjectionOptions): TabRef[] {
const layout = validateTabLayout(input);
const collapsed = options.collapsedGroupIds ?? new Set<string>();
const renderable = (ref: TabRef): boolean =>
ref.kind === 'session' ? options.liveSessionIds.has(ref.id) : options.openWebviewIds.has(ref.id);
const highlightedKey = options.highlighted ? keyOf(options.highlighted) : undefined;
const result: TabRef[] = [];
for (const group of layout.groups) {
for (const ref of group.refs) {
if (!renderable(ref)) continue;
if (collapsed.has(group.id) && keyOf(ref) !== highlightedKey) continue;
result.push({ ...ref });
}
}
for (const ref of layout.ungrouped) if (renderable(ref)) result.push({ ...ref });
return result;
}
+65 -2
View File
@@ -52,6 +52,7 @@ import {
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
@@ -92,6 +93,8 @@ import {
getAntigravityNotFoundMessage,
resolvePiDir,
getPiNotFoundMessage,
resolveGrokDir,
getGrokNotFoundMessage,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
@@ -802,6 +805,47 @@ function buildPiCommand(config?: PiConfig): string {
return parts.join(' ');
}
/**
* Build the Grok Build CLI (xAI `grok`) command with appropriate flags.
*
* The bypass switch is `--always-approve` ("auto-approve all tool executions",
* grok's `bypassPermissions` permission mode; config-level deny rules still
* apply on top). Absent config spawns bare `grok`, i.e. grok's own default
* ask-mode, which is why the multi-user clamp only needs the only-if-sent
* branch for grok. Flag surface verified against grok 1.0.5.
*
* `XAI_API_KEY` is deliberately never wired as a flag: secrets flow through
* socket-scoped `tmux setenv` (envOverrides), never the spawn command line.
*
* Like the sibling builders, every user value is regex-allowlisted and silently
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
*/
function buildGrokCommand(config?: GrokConfig): string {
const parts = ['grok'];
if (config?.alwaysApprove) {
parts.push('--always-approve');
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
// --resume and -c conflict; a valid explicit session id wins. Ids only:
// grok's --resume also accepts session TITLES, which are arbitrary user
// strings, so the id regex doubles as the no-titles rule here.
const safeSessionId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeSessionId) {
parts.push('--resume', safeSessionId);
} else if (config?.continueSession) {
parts.push('--continue');
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -845,6 +889,7 @@ export function buildSpawnCommand(options: {
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
@@ -894,6 +939,9 @@ export function buildSpawnCommand(options: {
if (options.mode === 'pi') {
return buildPiCommand(options.piConfig);
}
if (options.mode === 'grok') {
return buildGrokCommand(options.grokConfig);
}
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
// so a `$SHELL` here is expanded by the SERVER process's shell against the
@@ -1109,6 +1157,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
return `${modeCommand} --conversation ${resumeId}`;
case 'pi':
return `${modeCommand} --session ${resumeId}`;
case 'grok':
return `${modeCommand} --resume ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
@@ -1699,10 +1749,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi'
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' ? ['unset NO_COLOR'] : []),
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
? ['unset NO_COLOR']
: []),
// Stamp each Codex pane with a unique originator so the response-viewer
// can locate THIS pane's rollout exactly — codex writes the value into
// session_meta.originator of every rollout it creates. Without it,
@@ -1797,6 +1849,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolvePiDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'grok') {
const dir = resolveGrokDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
@@ -1846,6 +1902,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1906,6 +1963,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'pi' && !cliDir) {
throw new Error(getPiNotFoundMessage());
}
if (mode === 'grok' && !cliDir) {
throw new Error(getGrokNotFoundMessage());
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
@@ -1920,6 +1980,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2144,6 +2205,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2173,6 +2235,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
effort,
sessionName: name,
+1
View File
@@ -1013,6 +1013,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string
{ id: 'gemini', label: 'gemini', detail: 'Google Gemini' },
{ id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' },
{ id: 'pi', label: 'pi', detail: 'pi.dev' },
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' },
];
// ─────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -149,7 +149,7 @@ export type TuiAnswerResult =
export interface TuiQuickStartOptions {
caseName: string;
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi';
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
sessionName?: string;
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
parentSessionId?: string;
+3
View File
@@ -24,6 +24,7 @@ import type { TaskState } from './task.js';
import type { RalphLoopState } from './ralph.js';
import type { RespawnConfig } from './respawn.js';
import type { CronJob, CronJobRun } from './cron.js';
import type { TabLayout } from '../tab-layout.js';
// ========== Global Stats Types ==========
@@ -118,6 +119,8 @@ export interface AppState {
cronJobRuns?: Record<string, CronJobRun>;
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
sessionOrder?: string[];
/** Owner-scoped authoritative grouped tab layouts. */
tabLayouts?: Record<string, TabLayout>;
}
// ========== Default Configuration ==========
+31 -4
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' (which CLI backend)
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -16,6 +16,7 @@
* - 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)
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
*
* Cross-domain relationships:
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
@@ -44,11 +45,11 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
/** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi';
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
export type RemoteCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
>;
/**
@@ -157,7 +158,7 @@ export interface RemoteSessionInfo {
/** Which CLI backends a Docker case can run (same set as remote). */
export type DockerCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
>;
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
@@ -363,6 +364,30 @@ export interface PiConfig {
approveProjectTrust?: boolean;
}
/**
* Grok Build CLI (xAI `grok`) session configuration.
*
* Grok has Claude-style permission modes; the bypass switch is `--always-approve`
* ("auto-approve all tool executions", the CLI's `bypassPermissions` mode). Deny
* rules from `~/.grok/config.toml` / project `.grok/config.toml` still apply on
* top of it. Verified against grok 1.0.5.
*/
export interface GrokConfig {
/** Model ID (e.g. "grok-4.5", or a custom `[model.<name>]` from config.toml). Passed via --model. */
model?: string;
/**
* Auto-approve all tool executions (passes --always-approve). Absent = grok's
* own default permission mode (ask). Multi-user: forced off for non-granted
* owners by the only-if-sent clamp branch, like codex/antigravity — the
* absent-config spawn already defaults safe.
*/
alwaysApprove?: boolean;
/** Continue the most recent session for the working directory (-c). Skipped when resumeSessionId is set. */
continueSession?: boolean;
/** Resume a specific session by ID (--resume). Ids only, never titles or paths. */
resumeSessionId?: string;
}
/**
* Configuration for creating a new session
*/
@@ -526,6 +551,8 @@ export interface SessionState {
antigravityConfig?: AntigravityConfig;
/** Pi-specific configuration (only for mode === 'pi') */
piConfig?: PiConfig;
/** Grok-specific configuration (only for mode === 'grok') */
grokConfig?: GrokConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* @fileoverview Shared CLI executable resolution for the per-CLI resolvers.
*
* One lookup chain behind all six *-cli-resolver modules (claude, opencode,
* codex, gemini, antigravity, pi): the server process PATH first, then the
* One lookup chain behind all seven *-cli-resolver modules (claude, opencode,
* codex, gemini, antigravity, pi, grok): the server process PATH first, then the
* CLI's common install directories in order, then — last, because it is the
* only step that spawns anything — an interactive login shell, which is what
* finds nvm/Homebrew/user-npm installs when Codeman runs as a systemd/launchd
+151
View File
@@ -0,0 +1,151 @@
/**
* @fileoverview Resolve the Grok Build CLI (`grok`, xAI) binary across common install paths.
*
* Mirrors pi-cli-resolver.ts, version probe included: `grok` is another short
* name with known squatters (the unrelated `@vibe-kit/grok-cli` npm package also
* installs a `grok` bin), so a `which grok` hit is not by itself evidence that
* xAI's coding agent is installed. Every candidate is sanity-probed with
* `grok --version` and required to print a version-shaped string (the real CLI
* prints `grok 1.0.5 (5115b46bc9)`); a binary that fails the probe is treated
* as absent and the rejected path is logged. The probe cannot tell two
* version-printing `grok`s apart, which is why `GET /api/grok/status` surfaces
* path AND version: a misresolution is diagnosable rather than presenting as
* "the mode just doesn't work".
*
* The official installer (`curl -fsSL https://x.ai/cli/install.sh | bash`)
* places the binary in `~/.grok/bin` and symlinks it into `~/.local/bin`, so
* those two head the search list.
*
* @module utils/grok-cli-resolver
*/
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
type CliResolverHost,
} from './cli-executable-resolver.js';
/** Common directories where the Grok CLI binary may be installed */
const GROK_SEARCH_DIRS = [
join(homedir(), '.grok', 'bin'),
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), 'bin'),
];
/**
* A real `grok --version` prints `grok 1.0.5 (5115b46bc9)` (measured, 1.0.5).
*
* Exported and SHARED with the `grok` entry in `config/dependency-registry.ts`,
* so `codeman doctor` and the run mode cannot disagree about what counts as an
* installed grok (the same single-source rule as PI_VERSION_REGEX). Shape is
* dictated by the doctor's `extractVersion()` (first capture group, whole-output
* scan): hence a capturing group and a leading boundary instead of `^`. No `g`
* flag, so there is no shared `lastIndex` to reset.
*/
export const GROK_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/;
const GROK_NOT_FOUND = 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash';
/**
* Run `grok --version` on a candidate path and return the version token when it
* looks like the coding agent. Returns null for anything else: a missing
* binary, a non-zero exit, a hang (timeout), or output with no version-shaped
* token (which is how an unrelated `grok` on PATH gets rejected).
*
* Never runs under vitest: the suites must stay hermetic and must not depend on
* whether the dev box happens to have grok installed, and since `grok` is a
* name with known squatters, this probe would EXECUTE whatever binary of that
* name the machine carries. The shared resolver host is already inert under
* vitest, so this gate is defense in depth for any opted-in host that still
* carries the default probe; tests drive resolution via
* `createGrokResolverForTest`, whose injected probe bypasses it. Pinned by
* test/grok-cli-resolver.test.ts.
*/
function probeGrokVersion(binPath: string): string | null {
if (process.env.VITEST) return null;
try {
const out = execFileSync(binPath, ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// A stuck or hostile `grok` that ignores SIGTERM would survive the timeout
// and block the server (execFileSync keeps waiting after the signal).
killSignal: 'SIGKILL',
}).trim();
const candidate = GROK_VERSION_REGEX.exec(out)?.[1];
if (candidate) return candidate;
console.warn(`[GrokResolver] Ignoring ${binPath}: "grok --version" printed ${JSON.stringify(out.slice(0, 80))}`);
} catch (err) {
console.warn(`[GrokResolver] Ignoring ${binPath}: "grok --version" failed (${(err as Error).message})`);
}
return null;
}
type GrokVersionProbe = (binPath: string) => string | null;
function createGrokResolver(
host?: CliResolverHost,
versionProbe: GrokVersionProbe = probeGrokVersion,
now?: () => number
) {
return createCliExecutableResolver<string>(
{
binary: 'grok',
searchDirs: GROK_SEARCH_DIRS,
validateCandidate: (binPath) => {
const version = versionProbe(binPath);
return version ? { accepted: true, metadata: version } : { accepted: false };
},
now,
},
host
);
}
/**
* Creates an isolated Grok wrapper around an injected host, version probe and
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
* is exactly what the hermeticity test exercises.
*/
export function createGrokResolverForTest(host: CliResolverHost, versionProbe?: GrokVersionProbe, now?: () => number) {
return createGrokResolver(host, versionProbe ?? probeGrokVersion, now);
}
const grokResolver = createGrokResolver();
/**
* Finds the directory containing a verified `grok` binary.
* Checks the server PATH first, then the common install locations
* (`~/.grok/bin` leading, the official installer's target). Every candidate
* must pass the `grok --version` sanity probe before it is accepted.
*
* @returns Directory path, or null if not found
*/
export function resolveGrokDir(): string | null {
return grokResolver.resolve()?.directory ?? null;
}
/**
* Check if the Grok CLI is available on the system.
*/
export function isGrokAvailable(): boolean {
return resolveGrokDir() !== null;
}
export function getGrokNotFoundMessage(): string {
return formatCliNotFoundMessage(GROK_NOT_FOUND, grokResolver.diagnostics());
}
/**
* Version reported by the resolved `grok` binary, or null when grok is
* unavailable. Surfaced through `GET /api/grok/status` so a misresolution is
* diagnosable from the UI.
*/
export function getGrokCliVersion(): string | null {
return grokResolver.resolve()?.metadata ?? null;
}
+1
View File
@@ -45,5 +45,6 @@ export {
getAntigravityNotFoundMessage,
} from './antigravity-cli-resolver.js';
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-cli-resolver.js';
export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js';
+1
View File
@@ -14,3 +14,4 @@ export type { InfraPort, ScheduledRun } from './infra-port.js';
export type { AuthPort } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js';
export type { CronPort } from './cron-port.js';
export type { TabLayoutPort } from './tab-layout-port.js';
+1 -1
View File
@@ -7,7 +7,7 @@ import type { Session } from '../../session.js';
export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>;
addSession(session: Session): void;
addSession(session: Session): Promise<void>;
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
persistSessionState(session: Session): void;
+8
View File
@@ -0,0 +1,8 @@
/** @fileoverview Owner-scoped tab-layout capabilities exposed to route modules. */
import type { TabLayoutService } from '../../tab-layout-service.js';
export type { LegacyOrderActor, LegacyOrderPutResult, SessionOrderProjectionChange } from '../../tab-layout-service.js';
export interface TabLayoutPort {
readonly tabLayouts: TabLayoutService;
}
+117 -33
View File
@@ -919,6 +919,8 @@ class CodemanApp {
// Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder)
// and then applies the sidebar variant on top — do not call both.
this.applySessionListLayout();
this.applyTabOrientation();
this.initTabRailResize?.();
this.applyMonitorVisibility();
this.applyLineageLineSettings?.();
this._installLineageStripScrollListener?.();
@@ -986,6 +988,11 @@ class CodemanApp {
this.applySkin();
this.applyLocalization();
this.applySessionListLayout();
// A fresh device seeding tabOrientation from the server would otherwise
// show no rail until a resize or a settings save: the boot-time call ran
// before this async load resolved. Must stay AFTER applySessionListLayout
// (same ordering rule as the settings-save path).
this.applyTabOrientation?.();
this.applyMonitorVisibility();
this.applyLineageLineSettings?.();
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
@@ -2243,9 +2250,11 @@ class CodemanApp {
? 'Antigravity'
: mode === 'pi'
? 'Pi'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
: mode === 'grok'
? 'Grok'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
async toggleResponseViewer() {
@@ -3756,6 +3765,21 @@ class CodemanApp {
return layout === 'sidebar' || layout === 'sidebar-rich' ? layout : 'header';
}
resolveSessionSidebarFontSize(value) {
const size = Number(value);
// Default 12, matching the sidebar's historical 0.75rem name size: a user
// who never touches the slider must not get silently restyled (14 here
// bumped every existing sidebar install on the rail feature's release).
return Number.isInteger(size) && size >= 11 && size <= 18 ? size : 12;
}
applySessionSidebarFontSize(settings = null) {
const resolvedSettings = settings ?? this.loadAppSettingsFromStorage();
const size = this.resolveSessionSidebarFontSize(resolvedSettings?.sessionSidebarFontSize);
document.documentElement.style.setProperty('--session-sidebar-name-font-size', `${size}px`);
return size;
}
/**
* Reads the APPLIED layout off <html>, not the settings blob: this is called
* per dragover event and per tab in render loops, and getSessionListLayout()
@@ -3767,6 +3791,26 @@ class CodemanApp {
return document.documentElement.dataset.sessionList === 'sidebar';
}
_tabOrientation() {
return document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
}
/**
* True when the session list renders as a vertical column: the sidebar layout
* OR the vertical tab rail. Axis decisions (drag insertion side, active-tab
* scroll-into-view, floating-window anchors) must use THIS, not
* isSessionSidebarActive() alone — the rail leaves data-session-list at
* 'header', so the sidebar predicate reads a vertical rail as horizontal.
*/
_isVerticalTabList() {
return this.isSessionSidebarActive() || this._tabOrientation() === 'vertical';
}
shouldInlineSessionActions() {
if (this.isSessionSidebarActive()) return !this.isSessionSidebarCollapsed();
return this._tabOrientation() === 'vertical' && !document.documentElement.classList.contains('tab-rail-compact');
}
/**
* True when the sidebar is showing the DETAILED rows: the home screen's
* per-session line ("created 3d ago · working 12m") plus a status pill.
@@ -3859,17 +3903,22 @@ class CodemanApp {
*/
applySessionListLayout() {
const mode = this.getSessionListLayout();
this.applySessionSidebarFontSize();
// 'sidebar' and 'sidebar-rich' are the same column; only row detail differs.
const sidebar = mode === 'sidebar' || mode === 'sidebar-rich';
const collapsed = this.isSessionSidebarCollapsed();
const prevMode = document.documentElement.dataset.sessionList;
const prevDetail = document.documentElement.dataset.sidebarDetail;
const prevCollapsed = document.documentElement.dataset.sidebar;
const tabsEl = document.getElementById('sessionTabs');
const headerHost = document.getElementById('sessionTabsHost');
const sidebarList = document.getElementById('sessionSidebarList');
if (!tabsEl || !headerHost || !sidebarList) return;
const host = sidebar ? sidebarList : headerHost;
const rail = document.getElementById('tabRail');
const railOwnsTabs =
!sidebar && document.documentElement.getAttribute('data-tab-orientation') === 'vertical';
const host = sidebar ? sidebarList : railOwnsTabs && rail ? rail : headerHost;
if (tabsEl.parentElement !== host) host.appendChild(tabsEl);
document.documentElement.dataset.sessionList = sidebar ? 'sidebar' : 'header';
@@ -3878,7 +3927,7 @@ class CodemanApp {
// would let the sidebar CSS style a strip that has nothing to style.
document.documentElement.dataset.sidebarDetail = mode === 'sidebar-rich' ? 'rich' : 'simple';
document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded';
tabsEl.setAttribute('aria-orientation', sidebar ? 'vertical' : 'horizontal');
tabsEl.setAttribute('aria-orientation', host === headerHost ? 'horizontal' : 'vertical');
const btn = document.getElementById('sidebarToggleBtn');
if (btn) {
@@ -3931,7 +3980,8 @@ class CodemanApp {
const layoutChanged =
prevMode !== document.documentElement.dataset.sessionList ||
prevDetail !== document.documentElement.dataset.sidebarDetail;
if (layoutChanged && prevTall === this._tallTabsEnabled) {
const collapseChanged = prevCollapsed !== document.documentElement.dataset.sidebar;
if ((layoutChanged || collapseChanged) && prevTall === this._tallTabsEnabled) {
this._fullRenderSessionTabs();
}
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
@@ -4235,12 +4285,13 @@ class CodemanApp {
container.querySelector('.session-tab.active');
if (!tab) return;
// Sidebar layout: the list scrolls VERTICALLY in its own scroller, so the
// horizontal computeTabScrollLeft math below would always no-op (scrollLeft
// pinned at 0). With 25+ sessions the active row is routinely below the
// fold; 'nearest' never scrolls when it is already visible, and only the
// list's own scroller moves — the drawer and document stay put.
if (this.isSessionSidebarActive()) {
// Sidebar layout AND the vertical rail: the list scrolls VERTICALLY in its
// own scroller, so the horizontal computeTabScrollLeft math below would
// always no-op (scrollLeft pinned at 0). With 25+ sessions the active row
// is routinely below the fold; 'nearest' never scrolls when it is already
// visible, and only the list's own scroller moves — drawer/rail and
// document stay put.
if (this._isVerticalTabList()) {
tab.scrollIntoView({ block: 'nearest' });
return;
}
@@ -4271,12 +4322,13 @@ class CodemanApp {
/**
* Where a floating window (subagent / ultracode) attaches to its parent tab.
* Header strip: below the tab, connector runs vertically. Sidebar: to the
* RIGHT of the tab, connector runs horizontally — otherwise the window spawns
* on top of the sidebar and its bezier loops backwards underneath it.
* Header strip: below the tab, connector runs vertically. Sidebar AND the
* vertical rail: to the RIGHT of the tab, connector runs horizontally —
* otherwise the window spawns on top of the list and its bezier loops
* backwards underneath it.
*/
_tabAnchor(rect) {
if (this.isSessionSidebarActive()) {
if (this._isVerticalTabList()) {
return {
x: rect.right,
y: rect.top + rect.height / 2,
@@ -4474,9 +4526,17 @@ class CodemanApp {
const nameEl = tab.querySelector('.tab-name');
if (nameEl) {
const _p = parseSessionPrefix(name);
const _label = _p && _p.suffix ? _p.suffix : name;
if (nameEl.textContent !== _label) {
nameEl.textContent = _label;
if (nameEl.dataset.fullName !== name) {
nameEl.replaceChildren();
if (_p && _p.suffix) {
const prefix = document.createElement('span');
prefix.className = 'tab-name-prefix';
prefix.textContent = `${_p.prefix}: `;
nameEl.append(prefix, document.createTextNode(_p.suffix));
} else {
nameEl.textContent = name;
}
nameEl.dataset.fullName = name;
tab.title = _p && _p.suffix
? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix)
: (session.workingDir || '');
@@ -4527,9 +4587,11 @@ class CodemanApp {
// Need to add badge - insert before the action-icon overlay so the
// badge stays a direct child of the tab (outside .tab-actions)
const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents);
const actionsEl = tab.querySelector('.tab-actions');
const actionsEl = tab.querySelector(':scope > .tab-actions');
if (actionsEl) {
actionsEl.insertAdjacentHTML('beforebegin', badgeHtml);
} else {
tab.insertAdjacentHTML('beforeend', badgeHtml);
}
} else if (minimizedCount === 0 && subagentBadgeEl) {
// Count went to 0 - remove badge
@@ -4559,11 +4621,12 @@ class CodemanApp {
// The full-render path already redraws the connection SVG; this incremental
// one does not, and a badge appearing widens a tab and shifts every tab after
// it, sliding the lineage arcs off their anchors. Only pay for it when there
// is something anchored to tab rects: lineage arcs, or — in sidebar layout,
// where lineage is skipped and the edge count stays 0 — the subagent/
// ultracode connectors, whose rows a badge changes the HEIGHT of. Same
// widening as the strip-scroll listener in session-lineage.js.
if (this._lineageEdgeCount > 0 || this.isSessionSidebarActive()) this.updateConnectionLines();
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
// (sidebar, where lineage is skipped and the edge count stays 0, or the
// rail, which can show connectors with zero lineage edges too) — the
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
// Same widening as the strip-scroll listener in session-lineage.js.
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
this.applySidebarFilter(this._sidebarFilter);
}
@@ -4587,6 +4650,17 @@ class CodemanApp {
const defaults = this.getDefaultSettings();
const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false;
const orientation = window.CodemanTabOverflow?.resolveTabOrientation
? window.CodemanTabOverflow.resolveTabOrientation({
deviceType,
setting: settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal',
})
: 'horizontal';
if (orientation === 'vertical') {
container.classList.remove('tabs-auto-wrap');
return;
}
if (manualTwoRows || deviceType !== 'desktop') {
container.classList.remove('tabs-auto-wrap');
return;
@@ -4627,6 +4701,7 @@ class CodemanApp {
}
_fullRenderSessionTabs() {
this.closeTabRailActionMenu?.();
if (this._inlineRenameActive) return;
const container = this.$('sessionTabs');
@@ -4704,7 +4779,9 @@ class CodemanApp {
// JUST the description on the tab; the generated w<n>-<case> id moves to the
// tooltip and stays visible in the session settings modal.
const parsedName = parseSessionPrefix(name);
const tabLabel = parsedName && parsedName.suffix ? parsedName.suffix : name;
const tabLabel = parsedName && parsedName.suffix
? `<span class="tab-name-prefix">${escapeHtml(parsedName.prefix)}: </span>${escapeHtml(parsedName.suffix)}`
: escapeHtml(name);
const tabTooltip = parsedName && parsedName.suffix
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
: (session.workingDir || '');
@@ -4719,14 +4796,18 @@ class CodemanApp {
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"`
: '';
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}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData} 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(name)} session" ${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>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : ''}
<span class="tab-name" data-session-id="${id}">${escapeHtml(tabLabel)}</span>
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : ''}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
@@ -4735,7 +4816,7 @@ class CodemanApp {
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge}
${ultracodeBadge}
<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></span>
${inlineSessionActions ? '' : tabActionsHtml}
</div>`);
_tabIdx++;
}
@@ -4958,9 +5039,9 @@ class CodemanApp {
// inside the handler — these listeners survive a layout flip between
// renders, so capturing the axis at bind time would go stale.
// drag-over-left/-right keep their names and now read as before/after;
// the sidebar CSS just draws them as top/bottom edges.
// the sidebar/rail CSS just draws them as top/bottom edges.
const rect = tab.getBoundingClientRect();
const insertBefore = this.isSessionSidebarActive()
const insertBefore = this._isVerticalTabList()
? e.clientY < rect.top + rect.height / 2
: e.clientX < rect.left + rect.width / 2;
@@ -4984,7 +5065,7 @@ class CodemanApp {
// Determine insertion position (same axis rule as the dragover handler)
const rect = tab.getBoundingClientRect();
const insertBefore = this.isSessionSidebarActive()
const insertBefore = this._isVerticalTabList()
? e.clientY < rect.top + rect.height / 2
: e.clientX < rect.left + rect.width / 2;
@@ -6025,6 +6106,7 @@ class CodemanApp {
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
_cleanupSessionData(sessionId) {
this.closeTabRailActionMenu?.();
// If the deleted session is currently being renamed, abort the rename
// so the inline <input> doesn't ghost as a stale tab on screen.
if (this._activeRename?.sessionId === sessionId) {
@@ -6154,7 +6236,9 @@ class CodemanApp {
? 'Kill Tmux & Antigravity'
: session.mode === 'pi'
? 'Kill Tmux & Pi'
: 'Kill Tmux & Claude Code';
: session.mode === 'grok'
? 'Kill Tmux & Grok'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+82 -8
View File
@@ -10,7 +10,7 @@
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
* @globals {function} escapeHtml - XSS-safe HTML escaping
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (120 event types; must match backend src/web/sse-events.ts)
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (156 event types; must match backend src/web/sse-events.ts)
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
*
* @dependency None (first in load order)
@@ -156,6 +156,43 @@ function shouldAutoWrapTabs(input) {
return scrollWidth > clientWidth + 1;
}
function resolveTabOrientation(input) {
if (!input || input.setting !== 'vertical') return 'horizontal';
if (input.deviceType === 'mobile') return 'horizontal';
return 'vertical';
}
const TAB_RAIL_MIN_WIDTH = 208;
const TAB_RAIL_DEFAULT_WIDTH = 256;
const TAB_RAIL_MAX_WIDTH = 360;
function resolveTabRailWidth(input = {}) {
const viewportWidth = Number(input.viewportWidth);
const mainWidth = Number(input.mainWidth);
const minTerminalWidth = Number(input.minTerminalWidth);
const limits = [TAB_RAIL_MAX_WIDTH];
if (Number.isFinite(viewportWidth) && viewportWidth > 0) limits.push(Math.floor(viewportWidth * 0.4));
if (Number.isFinite(mainWidth) && mainWidth > 0 && Number.isFinite(minTerminalWidth) && minTerminalWidth > 0) {
limits.push(Math.floor(mainWidth - minTerminalWidth));
}
const effectiveMax = Math.max(TAB_RAIL_MIN_WIDTH, Math.min(...limits));
const requested = Number(input.width);
const width = Number.isFinite(requested) ? requested : TAB_RAIL_DEFAULT_WIDTH;
return Math.round(Math.min(effectiveMax, Math.max(TAB_RAIL_MIN_WIDTH, width)));
}
function resolveTabRailKeyboardWidth(input = {}) {
let width;
if (input.key === 'Home') width = TAB_RAIL_MIN_WIDTH;
else if (input.key === 'End') width = TAB_RAIL_MAX_WIDTH;
else if (input.key === 'Enter') width = TAB_RAIL_DEFAULT_WIDTH;
else if (input.key === 'ArrowLeft' || input.key === 'ArrowRight') {
const direction = input.key === 'ArrowLeft' ? -1 : 1;
width = (Number(input.currentWidth) || TAB_RAIL_DEFAULT_WIDTH) + direction * (input.shiftKey ? 32 : 8);
} else return null;
return resolveTabRailWidth({ ...input, width });
}
// Sliver of the neighbouring tab left visible when the strip scrolls a tab into
// view. Landing a tab flush against the edge reads as "this is the last one";
// the gap is what tells the user there is more strip to swipe to.
@@ -243,6 +280,9 @@ const LINEAGE_DIP_MAX_PX = 64;
// apart bled into one thick band instead of reading as three separate lines.
const LINEAGE_SIBLING_STEP_PX = 8;
const LINEAGE_STRIP_TOLERANCE_PX = 4;
const LINEAGE_VERTICAL_TRACK_INSET_PX = 6;
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
// (session-lineage.js). Every arc leaving one tab shares its colour however many
// workers it spawns; a child that spawns in turn gets its own for the arcs below it.
@@ -265,21 +305,44 @@ function computeLineagePath(input) {
const ch = Number(child.height) || 0;
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
const px = Number(parent.left) + pw / 2;
const cx = Number(child.left) + cw / 2;
if (!Number.isFinite(px) || !Number.isFinite(cx)) return null;
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
const strip = input?.strip;
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
const pLeft = Number(parent.left);
const cLeft = Number(child.left);
const pTop = Number(parent.top);
const cTop = Number(child.top);
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
if (orientation === 'vertical') {
const py = pTop + ph / 2;
const cy = cTop + ch / 2;
if (strip && Number(strip.height) > 0) {
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
if (py < min || py > max || cy < min || cy > max) return null;
}
const stripLeft =
strip && Number.isFinite(Number(strip.left))
? Number(strip.left)
: Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
const requestedTrack =
stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
return { d, endX: cLeft, endY: cy, sameRow: false };
}
const px = pLeft + pw / 2;
const cx = cLeft + cw / 2;
if (strip && Number(strip.width) > 0) {
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
if (px < min || px > max || cx < min || cx > max) return null;
}
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
const pTop = Number(parent.top);
const pBottom = pTop + ph;
const cTop = Number(child.top);
const cBottom = cTop + ch;
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
@@ -617,9 +680,17 @@ if (typeof window !== 'undefined') {
window.shouldSkipWebGL = shouldSkipWebGL;
window.CodemanTabOverflow = {
shouldAutoWrapTabs,
resolveTabOrientation,
computeTabScrollLeft,
TAB_SCROLL_REVEAL_PX,
};
window.CodemanTabRail = {
DEFAULT_WIDTH: TAB_RAIL_DEFAULT_WIDTH,
MIN_WIDTH: TAB_RAIL_MIN_WIDTH,
MAX_WIDTH: TAB_RAIL_MAX_WIDTH,
resolveWidth: resolveTabRailWidth,
resolveKeyboardWidth: resolveTabRailKeyboardWidth,
};
window.CodemanWsReconnect = {
plan: planWsReconnect,
};
@@ -628,6 +699,8 @@ if (typeof window !== 'undefined') {
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
COLORS: LINEAGE_COLORS,
};
window.CodemanConnectionLoss = {
@@ -969,6 +1042,7 @@ const SSE_EVENTS = {
// Web tabs (dashboard URLs)
WEBVIEW_CHANGED: 'webview:changed',
TAB_LAYOUT_CHANGED: 'tab:layoutChanged',
};
// ═══════════════════════════════════════════════════════════════
+5 -2
View File
@@ -78,6 +78,7 @@ const HOME_SESSIONS_MODE_BADGE = {
gemini: 'gm',
antigravity: 'ag',
pi: 'pi',
grok: 'gk',
};
Object.assign(CodemanApp.prototype, {
@@ -87,12 +88,14 @@ Object.assign(CodemanApp.prototype, {
/**
* Width-driven, like every other layout decision in the app. Explicitly yields
* to the phone overview: that surface already lists the same sessions, and two
* lists of the same thing on one screen is worse than none.
* to the phone overview and persistent vertical tab rail: those surfaces already
* list the same sessions, and two lists of the same thing on one screen is worse
* than none.
*/
shouldShowHomeSessions() {
if (this.isSoloWindow) return false;
if (this.shouldUseMobileOverview?.()) return false;
if (document.documentElement.getAttribute('data-tab-orientation') === 'vertical') return false;
// The sidebar layout already docks the full session list flush left at full
// height — the rail would render the same list right next to it (and z-wise
// UNDER it: sidebar 11, welcome overlay 10, rail inside the overlay).
+3
View File
@@ -109,6 +109,7 @@
'Run Gemini': '运行 Gemini',
'Run Antigravity': '运行 Antigravity',
'Run Pi': '运行 Pi',
'Run Grok': '运行 Grok',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
@@ -232,6 +233,8 @@
'Redraw Terminal Button': '重绘终端按钮',
'Tab Bar': '标签栏',
'Session List Layout': '会话列表布局',
'Session Name Font Size': '会话名称字体大小',
'Adjust only session names in the vertical sidebar.': '仅调整垂直侧边栏中的会话名称。',
'Header tab strip': '顶栏标签条',
'Left sidebar': '左侧边栏',
'Left sidebar simple': '左侧边栏(简洁)',
+63 -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 L=JSON.parse(localStorage.getItem(k)||'{}').sessionListLayout;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';}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';}</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';var W=Number(A.tabRailWidth);if(V&&Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');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';}</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)}
@@ -358,6 +358,20 @@
<!-- Main Terminal Area -->
<main class="main">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation">
<div
id="tabRailResizeHandle"
class="tab-rail-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize session rail"
aria-valuemin="208"
aria-valuemax="360"
aria-valuenow="256"
tabindex="0"
></div>
</aside>
<!-- Collapsible session sidebar (opt-in layout). Deliberately EMPTY in
markup: applySessionListLayout() moves #sessionTabs in here, so the
vertical list is the exact same DOM node as the header strip and every
@@ -430,6 +444,10 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Pi
</button>
<button class="welcome-btn welcome-btn-grok" id="welcomeGrokBtn" style="display: none;" onclick="app.setRunMode('grok'); app.runGrok()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Grok
</button>
</div>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
@@ -509,6 +527,7 @@
desktop (which never loads mobile.css) can never render it. -->
<div class="mobile-overview" id="mobileOverview" hidden></div>
</main>
<div class="tab-rail-resize-shield" id="tabRailResizeShield" aria-hidden="true" hidden></div>
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
<div class="project-insights-panel" id="projectInsightsPanel">
@@ -548,6 +567,7 @@
<div class="file-preview-actions">
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">&#x2398;</button>
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">&times;</button>
</div>
</div>
@@ -607,6 +627,9 @@
<button class="run-mode-option" data-mode="pi" onclick="app.setRunMode('pi')">
<span class="run-mode-dot pi"></span>Pi
</button>
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
<span class="run-mode-dot grok"></span>Grok
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell
@@ -884,6 +907,7 @@
<option value="gemini">Gemini</option>
<option value="antigravity">Antigravity</option>
<option value="pi">Pi</option>
<option value="grok">Grok</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -1869,6 +1893,29 @@
<div class="set-group">
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="tab orientation horizontal vertical side rail">
<div class="set-row-text">
<span class="set-row-label">Tab Orientation</span>
<span class="set-row-desc">Keep tabs in the header or place them beside the terminal. Phones stay horizontal.</span>
</div>
<select id="appSettingsTabOrientation" class="set-select">
<option value="horizontal">Horizontal (top)</option>
<option value="vertical">Vertical (side rail)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Width</span>
<span class="set-row-desc">Set the preferred rail width for this device.</span>
</div>
<select id="appSettingsTabRailWidth" class="set-select">
<option value="208">Compact (208px)</option>
<option value="256">Default (256px)</option>
<option value="320">Wide (320px)</option>
<option value="360">Maximum (360px)</option>
<option value="custom" disabled>Custom</option>
</select>
</div>
<div class="set-row has-field" data-search="session list layout sidebar tab strip vertical">
<div class="set-row-text">
<span class="set-row-label">Session List Layout</span>
@@ -1880,6 +1927,18 @@
<option value="sidebar-rich">Left sidebar</option>
</select>
</div>
<div class="set-row has-field" data-search="session sidebar name font size text">
<div class="set-row-text">
<span class="set-row-label" id="appSettingsSessionSidebarFontSizeLabel">Session Name Font Size</span>
<span class="set-row-desc">Adjust only session names in the vertical sidebar.</span>
</div>
<label class="set-range-field" for="appSettingsSessionSidebarFontSize">
<input type="range" id="appSettingsSessionSidebarFontSize" min="11" max="18" step="1" value="12"
aria-labelledby="appSettingsSessionSidebarFontSizeLabel"
oninput="document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent=this.value+' px'">
<output id="appSettingsSessionSidebarFontSizeValue" for="appSettingsSessionSidebarFontSize">12 px</output>
</label>
</div>
<div class="set-row" data-search="tall tabs folder name two rows">
<div class="set-row-text">
<span class="set-row-label">Tall Tabs</span>
@@ -2616,6 +2675,7 @@
<option value="opencode" data-cli="opencode">OpenCode</option>
<option value="antigravity" data-cli="antigravity">Antigravity</option>
<option value="pi" data-cli="pi">Pi</option>
<option value="grok" data-cli="grok">Grok</option>
<option value="shell">Shell (no agent)</option>
</select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
@@ -2756,7 +2816,7 @@
<div class="form-row">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi + tmux.</span>
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok + tmux.</span>
</div>
<div class="form-row">
<label>Network</label>
@@ -3323,6 +3383,7 @@
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script>
<script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script>
+1
View File
@@ -54,6 +54,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
{ mode: 'pi', label: 'Pi', short: 'Pi' },
{ mode: 'grok', label: 'Grok', short: 'Grok' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
+20
View File
@@ -972,6 +972,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(244, 114, 182, 0.5) !important;
}
/* Grok mode colors on mobile. Same `!important` rationale as the pi block above. */
.btn-toolbar.btn-run.mode-grok,
.btn-toolbar.btn-run-gear.mode-grok {
background: #1c1c1f !important;
border-color: rgba(212, 212, 216, 0.3) !important;
color: #f4f4f5 !important;
}
.btn-toolbar.btn-run.mode-grok:active,
.btn-toolbar.btn-run-gear.mode-grok:active {
background: #3f3f46 !important;
border-color: rgba(212, 212, 216, 0.5) !important;
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -3055,6 +3069,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
background: linear-gradient(135deg, #27272a, #52525b);
border-color: #18181b;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
+46 -1
View File
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
@@ -3313,6 +3313,11 @@ Object.assign(CodemanApp.prototype, {
// Stop whatever the previous preview was playing. Overwriting innerHTML
// only DETACHES a <video>/<audio>; a detached media element keeps playing.
this._stopFilePreviewMedia();
// Disarm detach until this load has a URL of its own: an early error return
// must not leave the button opening the PREVIOUS file in a new tab.
this.filePreviewDetachUrl = '';
const detachBtn = this.$('filePreviewDetachBtn');
if (detachBtn) detachBtn.hidden = true;
// Show overlay with loading state
overlay.classList.add('visible');
@@ -3340,6 +3345,19 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Every branch below renders from one of these routes, so the detach button
// can always offer the same bytes in a browser tab: docx/pptx through the
// server-converted PDF preview, everything else through the raw route.
// (html/htm arrive as a download there by design — file-raw serves them
// attachment-only so widening READ never widens RUN.)
const officeDoc = ext === 'docx' || ext === 'pptx';
this.filePreviewDetachUrl = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
: officeDoc
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
if (detachBtn) detachBtn.hidden = false;
// Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.)
@@ -3489,6 +3507,29 @@ Object.assign(CodemanApp.prototype, {
// audible and keeps streaming from the server. Closing has to stop it.
this._stopFilePreviewMedia();
this.filePreviewContent = '';
this.filePreviewDetachUrl = '';
const detachBtn = this.$('filePreviewDetachBtn');
if (detachBtn) detachBtn.hidden = true;
},
/**
* Open the previewed file in a browser tab and close the overlay.
*
* window.open is called WITHOUT the 'noopener' feature string: with it the
* call returns null even on success, which would make a blocked pop-up
* indistinguishable from a working one. The opener link is severed by hand
* instead, and a null return then reliably means the browser blocked it, in
* which case the overlay stays up so the user has not lost the file.
*/
detachFilePreview() {
if (!this.filePreviewDetachUrl) return;
const win = window.open(this.filePreviewDetachUrl, '_blank');
if (!win) {
this.showToast('Pop-up blocked: allow pop-ups for this site to detach previews', 'error');
return;
}
win.opener = null;
this.closeFilePreview();
},
/**
@@ -4127,6 +4168,10 @@ Object.assign(CodemanApp.prototype, {
}).catch(() => {
this.showToast('Failed to copy', 'error');
});
} else {
// Media/PDF/binary previews have no text buffer. Saying so beats the
// dead-button silence this used to be.
this.showToast('Nothing to copy in this preview', 'info');
}
},
+15 -5
View File
@@ -160,6 +160,8 @@ Object.assign(CodemanApp.prototype, {
const strip = document.getElementById('sessionTabs');
if (!strip) return;
const stripRect = strip.getBoundingClientRect();
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
for (const edge of edges) {
for (const id of [edge.parentId, edge.childId]) {
const key = 'tab:' + id;
@@ -175,7 +177,13 @@ Object.assign(CodemanApp.prototype, {
const childRect = rects.get('tab:' + edge.childId);
if (!parentRect || !childRect) continue;
const geom = compute({ parent: parentRect, child: childRect, strip: stripRect, depth: edge.depth });
const geom = compute({
parent: parentRect,
child: childRect,
strip: stripRect,
depth: edge.depth,
orientation,
});
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
@@ -222,10 +230,12 @@ Object.assign(CodemanApp.prototype, {
const strip = document.getElementById('sessionTabs');
if (!strip) return;
this._lineageScrollHandler = () => {
// Sidebar layout scrolls the SAME element vertically, and there the
// subagent/ultracode connectors anchor to tab rects too (lineage arcs are
// skipped, so _lineageEdgeCount alone would never redraw them).
if (this._lineageEdgeCount > 0 || this.isSessionSidebarActive?.()) this.updateConnectionLines();
// Sidebar layout and the vertical rail scroll the SAME element
// vertically, and there the subagent/ultracode connectors anchor to tab
// rects too (the sidebar skips lineage arcs entirely, and the rail can
// show connectors with zero lineage edges, so _lineageEdgeCount alone
// would never redraw them).
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
};
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
},
+118 -15
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi),
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok),
* session options modal (per-session settings, color picker, rename),
* session options tabs (Ralph config tab), case settings (CRUD, links),
* create case modal, and mobile case picker.
@@ -403,6 +403,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'pi') {
return await this.runPi();
}
if (mode === 'grok') {
return await this.runGrok();
}
if (mode === 'shell') {
return await this.runShell();
}
@@ -468,7 +471,7 @@ Object.assign(CodemanApp.prototype, {
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/
_refreshRunModeAvailability(menu) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']) {
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
}
@@ -565,7 +568,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'shell' ? 'Run SH' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
@@ -1278,6 +1281,66 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Launch a Grok Build (xAI `grok`) session.
*
* Sends `grokConfig: { alwaysApprove: true }` the way runAntigravity() sends
* `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
* work, so the Run button opts into grok's bypassPermissions mode
* (`--always-approve`; config-level deny rules still apply on top). The
* multi-user clamp forces it back off for non-granted owners server-side.
*/
async runGrok() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run grok on the OTHER side: skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Grok session in ${caseName}...`);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/grok/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'grok',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
grokConfig: { alwaysApprove: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Grok');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -1343,7 +1406,7 @@ Object.assign(CodemanApp.prototype, {
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok';
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -1373,7 +1436,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for external CLI sessions
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
@@ -1788,15 +1851,22 @@ Object.assign(CodemanApp.prototype, {
const session = this.sessions.get(sessionId);
if (!session) return;
this._activeRename?.cancel();
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
if (!tabName) return;
// Prevent tab re-renders from destroying the input while renaming
this._inlineRenameActive = true;
tabName.classList.add('tab-name-renaming');
const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name);
const originalContent = tabName.textContent;
const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
const restoreOriginalChildren = () => {
tabName.replaceChildren(...originalChildren.map((node) => node.cloneNode(true)));
};
// Clear existing content to make room for the input element
tabName.textContent = '';
while (tabName.firstChild) tabName.removeChild(tabName.firstChild);
@@ -1804,6 +1874,7 @@ Object.assign(CodemanApp.prototype, {
// If prefix detected, show it as non-editable label
if (parsed) {
const prefixLabel = document.createElement('span');
prefixLabel.className = 'tab-rename-prefix';
prefixLabel.textContent = parsed.prefix + ': ';
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
tabName.appendChild(prefixLabel);
@@ -1816,36 +1887,67 @@ Object.assign(CodemanApp.prototype, {
input.className = 'tab-rename-input';
// 80px is tuned for the narrow header tab; a full-width sidebar row can and
// should give the whole line to the input.
const renameWidth = this.isSessionSidebarActive?.() ? '100%' : '80px';
const renameWidth = tabName.closest('.tab-rail') ? 'auto' : this.isSessionSidebarActive?.() ? '100%' : '80px';
input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
tabName.appendChild(input);
input.focus();
input.select();
const finishRename = async ({ commit }) => {
if (!this._inlineRenameActive) return; // prevent double-fire
let editSettled = false;
let invalidated = false;
let completed = false;
const releaseRenderGuard = () => {
if (this._activeRename !== renameHandle) return;
this._inlineRenameActive = false;
};
const completeCurrentRename = () => {
if (this._activeRename !== renameHandle) return;
completed = true;
releaseRenderGuard();
this._activeRename = null;
this.renderSessionTabs();
};
const cancelRename = () => {
if (invalidated || completed) return;
invalidated = true;
editSettled = true;
tabName.classList.remove('tab-name-renaming');
restoreOriginalChildren();
completeCurrentRename();
};
const finishRename = async ({ commit }) => {
if (editSettled || invalidated) return;
editSettled = true;
tabName.classList.remove('tab-name-renaming');
// Aborted (e.g. the session was deleted mid-rename, or Escape): re-render
// so any ghost DOM is replaced with the canonical tab list, and skip the
// API call — a cancel must not fire a stale rename PUT.
if (!commit) {
this.renderSessionTabs();
cancelRename();
return;
}
if (this._activeRename !== renameHandle) return;
releaseRenderGuard();
const suffix = input.value.trim();
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
tabName.textContent = fullName || originalContent;
if (fullName === session.name) restoreOriginalChildren();
else tabName.textContent = fullName || originalContent;
// Skip the API call if the session vanished between focus and blur.
const stillExists = this.sessions.has(sessionId);
if (stillExists && fullName !== session.name) {
const confirmed = await this._putSessionName(sessionId, fullName);
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
if (confirmed === null) {
tabName.textContent = originalContent;
restoreOriginalChildren();
this.showToast('Failed to rename', 'error');
} else {
// The re-render below repaints from this.sessions, so the new name has
@@ -1854,14 +1956,15 @@ Object.assign(CodemanApp.prototype, {
}
}
// Re-render tabs to restore full tab structure
this.renderSessionTabs();
completeCurrentRename();
};
// Register only after the input is wired so a throw above can't strand state.
this._activeRename = {
const renameHandle = {
sessionId,
cancel: () => finishRename({ commit: false }),
cancel: cancelRename,
};
this._activeRename = renameHandle;
input.addEventListener('blur', () => finishRename({ commit: true }));
input.addEventListener('keydown', (e) => {
@@ -3114,7 +3217,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
},
set(mode) {
this._runMode =
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'claude'
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'claude'
? mode
: 'claude';
},
+64 -1
View File
@@ -400,9 +400,20 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
document.getElementById('appSettingsTabOrientation').value =
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
width: settings.tabRailWidth ?? defaults.tabRailWidth ?? 256,
}) ?? 256;
this.syncTabRailWidthSetting?.(tabRailWidth);
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
settings.sessionSidebarFontSize ?? defaults.sessionSidebarFontSize
);
document.getElementById('appSettingsSessionSidebarFontSize').value = String(sessionSidebarFontSize);
document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent = `${sessionSidebarFontSize} px`;
// Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
const allowedToolsRow = document.getElementById('allowedToolsRow');
@@ -1199,6 +1210,7 @@ Object.assign(CodemanApp.prototype, {
['welcomeAntigravityBtn', 'antigravity'],
['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'],
['welcomeGrokBtn', 'grok'],
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
// without cloudflared can only ever produce "cloudflared not found".
['welcomeTunnelBtn', 'cloudflared'],
@@ -2027,8 +2039,13 @@ Object.assign(CodemanApp.prototype, {
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
document.getElementById('appSettingsSessionSidebarFontSize').value
),
skin: document.getElementById('appSettingsSkin').value,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
@@ -2178,6 +2195,7 @@ Object.assign(CodemanApp.prototype, {
// Re-parents #sessionTabs between header host and sidebar if the layout
// changed, then calls applyTabWrapSettings() itself — do not call both.
this.applySessionListLayout();
this.applyTabOrientation({ settleRailWidth: true });
this.applyLineageLineSettings?.();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
@@ -2423,7 +2441,10 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: false,
ralphTrackerEnabled: false,
tabTwoRows: false,
tabOrientation: 'horizontal',
tabRailWidth: 256,
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
cjkInputEnabled: false,
terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel
webglRendererEnabled: false, // mobile always uses the DOM renderer
@@ -2664,6 +2685,48 @@ Object.assign(CodemanApp.prototype, {
}
},
applyTabOrientation(options = {}) {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const sidebarOwnsTabs = this.isSessionSidebarActive?.() === true;
const orientation =
!this.isSoloWindow && !sidebarOwnsTabs && window.CodemanTabOverflow?.resolveTabOrientation
? window.CodemanTabOverflow.resolveTabOrientation({
deviceType: MobileDetection.getDeviceType(),
setting: settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal',
})
: 'horizontal';
const root = document.documentElement;
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
root.setAttribute('data-tab-orientation', orientation);
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
const headerHost = document.getElementById('sessionTabsHost');
if (!sidebarOwnsTabs && tabsEl && rail && headerHost) {
if (orientation === 'vertical') {
if (tabsEl.parentElement !== rail) rail.appendChild(tabsEl);
} else if (tabsEl.parentElement !== headerHost) {
headerHost.appendChild(tabsEl);
}
}
if (tabsEl) {
tabsEl.setAttribute('aria-orientation', sidebarOwnsTabs || orientation === 'vertical' ? 'vertical' : 'horizontal');
}
const settleRailWidth =
options.settleRailWidth === true && (orientation === 'vertical' || previous !== orientation);
this.applyTabRailWidth?.({ settle: settleRailWidth });
if (previous !== orientation) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.fitAddon?.fit();
this._fullRenderSessionTabs?.();
this._updateConnectionLinesImmediate?.();
this._refreshHomeSessionsIfVisible?.();
}
},
applyTabWrapSettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -2897,7 +2960,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily',
'language',
+350 -3
View File
@@ -62,6 +62,7 @@
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
--header-height: 36px;
--toolbar-height: 42px;
--tab-rail-width: 256px;
--sidebar-width: 260px;
--sidebar-width-rich: 300px; /* detailed rows carry a stamps line as well */
--sidebar-width-collapsed: 44px; /* == --touch-target-min */
@@ -347,7 +348,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.history-view-all-btn,
.session-tab .tab-mode.gemini,
.session-tab .tab-mode.antigravity,
.session-tab .tab-mode.pi
.session-tab .tab-mode.pi,
.session-tab .tab-mode.grok
) {
color: var(--accent-d);
}
@@ -574,6 +576,99 @@ body {
background: var(--border-light);
}
.tab-rail {
display: none;
flex: 0 0 var(--tab-rail-width);
width: var(--tab-rail-width);
min-width: 0;
overflow: hidden;
background: var(--glass-bg);
border-right: 1px solid var(--glass-border);
position: relative;
z-index: 11;
}
html[data-tab-orientation='vertical'] .tab-rail {
display: flex;
flex-direction: column;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tabs {
--lineage-vertical-gutter: 24px;
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
gap: 2px;
padding: 0.35rem;
padding-left: calc(0.35rem + var(--lineage-vertical-gutter));
overflow-x: hidden;
overflow-y: auto;
max-height: none;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab {
width: 100%;
max-width: none;
justify-content: flex-start;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab > * {
flex-shrink: 0;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
flex: 1 1 auto;
min-width: 0;
}
html[data-tab-orientation='vertical'] .header-right {
margin-left: auto;
}
.tab-rail-resize-handle {
position: absolute;
z-index: 2;
top: 0;
right: 0;
bottom: 0;
width: 16px;
cursor: ew-resize;
touch-action: none;
transition: background-color 0.15s ease;
}
.tab-rail-resize-handle:hover,
.tab-rail-resize-handle:focus-visible {
background: color-mix(in srgb, var(--accent) 24%, transparent);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
html:not([data-tab-orientation='vertical']) .tab-rail-resize-handle {
display: none;
}
.tab-rail-resize-shield:not([hidden]) {
display: block;
position: fixed;
inset: 0;
z-index: 10000;
cursor: ew-resize;
}
body.tab-rail-resizing,
body.tab-rail-resizing * {
cursor: ew-resize !important;
user-select: none !important;
}
@media (prefers-reduced-motion: reduce) {
.tab-rail,
.tab-rail-resize-handle {
transition: none !important;
}
}
.session-tab {
display: flex;
align-items: center;
@@ -1421,6 +1516,44 @@ html[data-line-anim="packet"] .connection-line.line-enter {
text-overflow: ellipsis;
}
.session-tab .tab-name-prefix {
display: none;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
white-space: normal;
line-height: 1.25;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
display: inline;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name.tab-name-renaming {
display: flex;
align-items: center;
-webkit-box-orient: initial;
-webkit-line-clamp: unset;
line-clamp: unset;
overflow: visible;
}
html[data-tab-orientation='vertical'] .tab-name-renaming .tab-rename-prefix {
flex: 0 0 auto;
}
html[data-tab-orientation='vertical'] .tab-name-renaming .tab-rename-input {
flex: 1 1 0;
width: auto;
min-width: 0;
}
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
.session-tab .tab-folder {
font-size: 0.6rem;
@@ -2152,6 +2285,123 @@ html[data-line-anim="packet"] .connection-line.line-enter {
align-items: center;
}
.session-tab .tab-more {
display: none;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.5rem;
padding: 0;
visibility: hidden;
pointer-events: none;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font: 700 1rem/1 monospace;
cursor: pointer;
}
html[data-session-list='sidebar'][data-sidebar='expanded'] .session-sidebar .tab-name-row > .tab-actions > .tab-more,
html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail .tab-name-row > .tab-actions > .tab-more,
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab.active > .tab-actions > .tab-more {
display: inline-flex;
}
.session-tab:hover > .tab-actions > .tab-more,
.session-tab:focus-within > .tab-actions > .tab-more,
.session-tab.active > .tab-actions > .tab-more,
html[data-session-list='sidebar'][data-sidebar='expanded'] .session-sidebar
:is(.session-tab:hover, .session-tab:focus-within, .session-tab.active)
.tab-name-row
> .tab-actions
> .tab-more,
html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail
:is(.session-tab:hover, .session-tab:focus-within, .session-tab.active)
.tab-name-row
> .tab-actions
> .tab-more {
visibility: visible;
pointer-events: auto;
}
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-detach, .tab-close) {
display: none;
}
@media (pointer: coarse) {
.session-tab > .tab-actions > .tab-more,
html[data-session-list='sidebar'][data-sidebar='expanded']
.session-sidebar
.session-tab
.tab-name-row
> .tab-actions
> .tab-more,
html[data-tab-orientation='vertical']:not(.tab-rail-compact)
.tab-rail
.session-tab
.tab-name-row
> .tab-actions
> .tab-more {
visibility: visible;
pointer-events: auto;
}
}
.tab-rail-action-menu {
position: fixed;
z-index: 2000;
display: grid;
min-width: 180px;
padding: 0.3rem;
border: 1px solid var(--glass-border);
border-radius: var(--btn-radius);
background: var(--floating-bg);
box-shadow: var(--elevated-shadow);
}
.tab-rail-action-menu button {
padding: 0.45rem 0.6rem;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.tab-rail-action-menu button:hover,
.tab-rail-action-menu button:focus-visible {
background: var(--control-bg-hover);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tab-rail-action-menu .danger {
color: var(--red);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field {
display: grid;
grid-template-columns: minmax(112px, 1fr) 44px;
align-items: center;
gap: 8px;
width: min(220px, 44vw);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field input {
width: 100%;
accent-color: var(--accent);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field output {
color: var(--text);
font-family: var(--font-mono);
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device).
settings-ui.js mirrors the setting as the tabs-show-detach class on <html>.
A tab that is ALREADY detached keeps its icon regardless: it is the
@@ -2246,6 +2496,11 @@ body.solo-mode .btn-lifecycle-log {
color: #f472b6;
}
.session-tab .tab-mode.grok {
background: rgba(212, 212, 216, 0.18);
color: #d4d4d8;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -3610,6 +3865,23 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transform: translateY(-1px);
}
/* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok
and .run-mode-dot.grok so the welcome action reads as the same backend. */
.welcome-btn-grok {
background: linear-gradient(135deg, #131316 0%, #27272a 55%, #3f3f46 100%);
border-color: rgba(212, 212, 216, 0.4);
color: #f4f4f5;
box-shadow: 0 2px 8px rgba(212, 212, 216, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.welcome-btn-grok:hover {
background: linear-gradient(135deg, #1f1f23 0%, #3f3f46 55%, #52525b 100%);
box-shadow: 0 4px 20px rgba(212, 212, 216, 0.22), 0 0 40px rgba(161, 161, 170, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(228, 228, 231, 0.5);
color: #fafafa;
transform: translateY(-1px);
}
.welcome-btn-gemini {
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
border-color: rgba(96, 165, 250, 0.4);
@@ -4684,6 +4956,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #fff1f7;
}
/* Grok mode colors. Same cascade note as pi above: this base-sheet pair only
renders on the `og` skin — the nested `html:not([data-skin="og"])` block
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so grok also
carries a rule inside that block (search `.btn-toolbar.btn-run.mode-grok`). */
.btn-toolbar.btn-run.mode-grok,
.btn-toolbar.btn-run-gear.mode-grok {
background: linear-gradient(135deg, #131316 0%, #27272a 55%, #3f3f46 100%);
border-color: rgba(212, 212, 216, 0.5);
color: #f4f4f5;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-grok:hover,
.btn-toolbar.btn-run-gear.mode-grok:hover {
background: linear-gradient(135deg, #1f1f23 0%, #3f3f46 55%, #52525b 100%);
box-shadow: 0 0 12px rgba(212, 212, 216, 0.28), 0 2px 8px rgba(63, 63, 70, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(228, 228, 231, 0.6);
color: #fafafa;
}
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -4767,6 +5058,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.gemini { background: #8ab4f8; }
.run-mode-dot.antigravity { background: #22d3ee; }
.run-mode-dot.pi { background: #f472b6; }
.run-mode-dot.grok { background: #a1a1aa; }
.run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width;
@@ -14102,6 +14394,15 @@ html:not([data-skin="og"]) {
color: #fff1f7;
}
.btn-toolbar.btn-run.mode-pi:hover { box-shadow: 0 0 14px -2px rgba(244, 114, 182, 0.45); }
/* Grok keeps its charcoal identity on the non-og skins. Same specificity trap
as pi above: without this rule the generic `.btn-toolbar.btn-run` in this
nested block wins and grok renders as generic claude blue. */
.btn-toolbar.btn-run.mode-grok {
background: linear-gradient(135deg, #27272a, #52525b);
border-color: #18181b;
color: #fafafa;
}
.btn-toolbar.btn-run.mode-grok:hover { box-shadow: 0 0 14px -2px rgba(161, 161, 170, 0.5); }
.btn-toolbar.btn-run-gear {
background: var(--accent-d);
border-color: var(--accent);
@@ -14754,6 +15055,10 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
display: none;
}
html[data-tab-orientation='vertical'] .home-sessions {
display: none !important;
}
/* Belt and braces with shouldShowHomeSessions(): a resize that outruns the
matchMedia listener must never leave the column overlapping the content. */
@media (max-width: 1179px) {
@@ -16530,6 +16835,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
padding: 0 22px 22px;
}
#sessionOptionsModal #context-tab {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.25rem 1rem;
align-items: start;
}
#sessionOptionsModal .set-section {
padding-top: 18px;
}
@@ -16547,6 +16859,22 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
font-size: 0.75rem;
}
@media (min-width: 1200px) {
#sessionOptionsModal .modal-content.modal-lg {
width: min(1120px, 96vw);
max-width: min(1120px, 96vw);
}
#sessionOptionsModal #context-tab {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#sessionOptionsModal #context-tab > .set-section-head,
#sessionOptionsModal #context-tab > .set-section-blurb {
grid-column: 1 / -1;
}
}
#sessionOptionsModal .set-group + .set-group {
margin-top: 16px;
}
@@ -16836,10 +17164,27 @@ html[data-session-list="sidebar"] .session-sidebar .tab-info {
min-width: 0;
}
html[data-session-list="sidebar"] .session-sidebar .tab-name-row,
html[data-session-list="sidebar"] .session-sidebar .tab-name {
min-width: 0;
max-width: none;
}
html[data-session-list='sidebar'] .session-sidebar .tab-name {
flex: 0 1 auto;
white-space: nowrap;
font-size: var(--session-sidebar-name-font-size, 12px);
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name {
font-size: var(--session-sidebar-name-font-size, 12px);
}
html[data-session-list='sidebar'] .session-sidebar .tab-actions,
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-row > .tab-actions {
flex-shrink: 0;
}
/* Reveal-on-hover reads badly on a 40px-tall full-width row, so keep the row
actions permanently visible on the active session — no layout jitter when
the pointer crosses the list. */
@@ -16853,11 +17198,13 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-clos
/* Drag-reorder indicators become horizontal edges. The class names stay
drag-over-left / drag-over-right (they read as before/after now) so app.js,
the base rules above and the generated gesture bundle need no renaming. */
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-left {
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-left,
html[data-tab-orientation='vertical'] .tab-rail .session-tab.drag-over-left {
box-shadow: 0 -2px 0 0 var(--accent);
}
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-right {
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-right,
html[data-tab-orientation='vertical'] .tab-rail .session-tab.drag-over-right {
box-shadow: 0 2px 0 0 var(--accent);
}
+329
View File
@@ -0,0 +1,329 @@
/**
* @fileoverview Accessible, device-local vertical session-rail sizing.
*
* Pointer + keyboard resizing for the vertical tab rail (`tabOrientation:
* 'vertical'`), with a preferred width persisted per device (`tabRailWidth`)
* and re-clamped against the viewport on resize. During a drag it takes
* ownership of terminal refits (`_tabRailResizeOwnsObserver` suppresses
* terminal-ui's throttled ResizeObserver) and performs ONE settle-time resize,
* reverting per-frame below 40 columns so the PTY is never thrashed.
*
* @dependency CodemanApp (app.js) - methods attach to its prototype
* @dependency CodemanTabRail (constants.js) - width policy (resolveWidth, bounds)
* @loadorder 6.5 (after app.js, before terminal-ui.js)
*/
Object.assign(CodemanApp.prototype, {
_getTabRailMinimumTerminalWidth() {
const cellWidth = this.terminal?._core?._renderService?.dimensions?.css?.cell?.width;
if (Number.isFinite(cellWidth) && cellWidth > 0) return Math.ceil(cellWidth * 40 + 24);
return 420;
},
_getTabRailBounds() {
const main = document.querySelector('.main');
return {
viewportWidth: window.innerWidth,
mainWidth: main?.clientWidth || window.innerWidth,
minTerminalWidth: this._getTabRailMinimumTerminalWidth(),
};
},
_getCurrentTabRailWidth() {
const fromCss = Number.parseFloat(document.documentElement.style.getPropertyValue('--tab-rail-width'));
if (Number.isFinite(fromCss)) return fromCss;
const measured = document.getElementById('tabRail')?.getBoundingClientRect?.().width;
return Number.isFinite(measured) && measured > 0 ? measured : window.CodemanTabRail?.DEFAULT_WIDTH || 256;
},
readTabRailWidthSetting() {
const select = document.getElementById('appSettingsTabRailWidth');
if (!select) return this._getCurrentTabRailWidth();
if (select.value === 'custom') return Number(select.dataset.currentWidth) || this._getCurrentTabRailWidth();
return Number(select.value) || window.CodemanTabRail?.DEFAULT_WIDTH || 256;
},
syncTabRailWidthSetting(width) {
const select = document.getElementById('appSettingsTabRailWidth');
if (!select) return;
const rounded = Math.round(width);
select.dataset.currentWidth = String(rounded);
const preset = select.querySelector(`option[value="${rounded}"]`);
if (preset) {
select.value = String(rounded);
return;
}
const custom = select.querySelector('option[value="custom"]');
if (custom) custom.textContent = `Custom (${rounded}px)`;
select.value = 'custom';
},
_setTabRailWidth(width) {
const policy = window.CodemanTabRail;
if (!policy) return 256;
const preferred = policy.resolveWidth({ width });
const bounds = this._getTabRailBounds();
const resolved = policy.resolveWidth({ width: preferred, ...bounds });
const effectiveMax = policy.resolveWidth({ width: policy.MAX_WIDTH, ...bounds });
const root = document.documentElement;
root.style.setProperty('--tab-rail-width', `${resolved}px`);
const wasCompact = root.classList.contains('tab-rail-compact');
const compact = resolved < 240;
root.classList.toggle('tab-rail-compact', compact);
if (wasCompact !== compact) this._fullRenderSessionTabs?.();
const handle = document.getElementById('tabRailResizeHandle');
if (handle) {
handle.setAttribute('aria-valuemax', String(effectiveMax));
handle.setAttribute('aria-valuenow', String(resolved));
}
this.syncTabRailWidthSetting(preferred);
return resolved;
},
_persistTabRailWidth(width) {
const settings = this.loadAppSettingsFromStorage();
if (settings.tabRailWidth === width) return;
settings.tabRailWidth = width;
this.saveAppSettingsToStorage(settings);
},
_claimTabRailResize() {
this._tabRailResizeOwnsObserver = true;
clearTimeout(this._tabRailResizeWatchdog);
clearTimeout(this._tabRailReleaseTimer);
if (this._tabRailReleaseRaf) cancelAnimationFrame(this._tabRailReleaseRaf);
this._armTabRailResizeWatchdog();
if (this._resizeRaf) cancelAnimationFrame(this._resizeRaf);
if (this._resizeTimeout) clearTimeout(this._resizeTimeout);
this._resizeRaf = null;
this._resizeTimeout = null;
},
_armTabRailResizeWatchdog() {
this._tabRailResizeWatchdog = setTimeout(() => {
this._tabRailResizeWatchdog = null;
if (document.body.classList.contains('tab-rail-resizing')) {
this._armTabRailResizeWatchdog();
return;
}
this._tabRailResizeOwnsObserver = false;
}, 1000);
},
_releaseTabRailResize() {
clearTimeout(this._tabRailResizeWatchdog);
this._tabRailResizeWatchdog = null;
const release = () => {
clearTimeout(this._tabRailReleaseTimer);
this._tabRailReleaseTimer = null;
this._tabRailReleaseRaf = null;
this._tabRailResizeOwnsObserver = false;
};
if (typeof requestAnimationFrame === 'function') {
this._tabRailReleaseRaf = requestAnimationFrame(() => {
this._tabRailReleaseRaf = requestAnimationFrame(release);
});
this._tabRailReleaseTimer = setTimeout(release, 250);
} else release();
},
_scheduleTabRailSettle(effective, preferred = effective) {
clearTimeout(this._tabRailSettleTimer);
this._tabRailSettleTimer = setTimeout(async () => {
this._tabRailSettleTimer = null;
this._persistTabRailWidth(preferred);
try {
if (this.activeSessionId && this.sendResize) await this.sendResize(this.activeSessionId);
else this.fitAddon?.fit();
this._updateConnectionLinesImmediate?.();
} catch (error) {
console.warn('Failed to resize terminal after rail resize:', error);
} finally {
this._releaseTabRailResize();
}
}, 150);
},
applyTabRailWidth(options = {}) {
const settings = this.loadAppSettingsFromStorage();
const requested = settings.tabRailWidth ?? window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
const preferred = window.CodemanTabRail?.resolveWidth({ width: requested }) ?? 256;
if (options.settle) this._claimTabRailResize();
const resolved = this._setTabRailWidth(preferred);
if (options.persist !== false && requested !== preferred) this._persistTabRailWidth(preferred);
if (options.settle) this._scheduleTabRailSettle(resolved, preferred);
return resolved;
},
_applyTabRailPointerWidth(clientX) {
const main = document.querySelector('.main');
if (!main) return this._getCurrentTabRailWidth();
const previous = this._getCurrentTabRailWidth();
const width = this._setTabRailWidth(clientX - main.getBoundingClientRect().left);
let proposed = null;
try {
proposed = this.fitAddon?.proposeDimensions?.();
} catch {}
return proposed && proposed.cols < 40 ? this._setTabRailWidth(previous) : width;
},
_queueTabRailPointerWidth(clientX) {
this._tabRailPendingClientX = clientX;
if (this._tabRailPointerRaf) return;
this._tabRailPointerRaf = requestAnimationFrame(() => {
this._tabRailPointerRaf = null;
this._tabRailDragWidth = this._applyTabRailPointerWidth(this._tabRailPendingClientX);
});
},
_finishTabRailDrag(handle, pointerId) {
if (!document.body.classList.contains('tab-rail-resizing')) return;
if (this._tabRailPointerRaf) {
cancelAnimationFrame(this._tabRailPointerRaf);
this._tabRailPointerRaf = null;
this._tabRailDragWidth = this._applyTabRailPointerWidth(this._tabRailPendingClientX);
}
document.body.classList.remove('tab-rail-resizing');
const shield = document.getElementById('tabRailResizeShield');
if (shield) shield.hidden = true;
try {
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
} catch {}
const preferred = window.CodemanTabRail?.resolveWidth({ width: this._tabRailDragWidth }) ?? this._tabRailDragWidth;
this._scheduleTabRailSettle(this._tabRailDragWidth || this._getCurrentTabRailWidth(), preferred);
},
_onTabRailKeyDown(event) {
const width = window.CodemanTabRail?.resolveKeyboardWidth({
key: event.key,
shiftKey: event.shiftKey,
currentWidth: this._getCurrentTabRailWidth(),
...this._getTabRailBounds(),
});
if (width === null || width === undefined) return;
event.preventDefault();
this._claimTabRailResize();
const effective = this._setTabRailWidth(width);
this._scheduleTabRailSettle(effective, window.CodemanTabRail.resolveWidth({ width }));
},
initTabRailResize() {
const handle = document.getElementById('tabRailResizeHandle');
if (!handle || handle.dataset.ready === '1') return;
handle.dataset.ready = '1';
this.applyTabRailWidth();
handle.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
event.preventDefault();
this.closeTabRailActionMenu();
this._claimTabRailResize();
this._tabRailDragWidth = this._getCurrentTabRailWidth();
this._tabRailPendingClientX = event.clientX;
document.body.classList.add('tab-rail-resizing');
const shield = document.getElementById('tabRailResizeShield');
if (shield) shield.hidden = false;
try {
handle.setPointerCapture(event.pointerId);
} catch {
document.body.classList.remove('tab-rail-resizing');
if (shield) shield.hidden = true;
this._releaseTabRailResize();
}
});
handle.addEventListener('pointermove', (event) => {
if (handle.hasPointerCapture?.(event.pointerId)) this._queueTabRailPointerWidth(event.clientX);
});
handle.addEventListener('pointerup', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('pointercancel', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('lostpointercapture', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('keydown', (event) => this._onTabRailKeyDown(event));
handle.addEventListener('dblclick', (event) => {
event.preventDefault();
this._claimTabRailResize();
const preferred = window.CodemanTabRail?.DEFAULT_WIDTH || 256;
const effective = this._setTabRailWidth(preferred);
this._scheduleTabRailSettle(effective, preferred);
});
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
this.applyTabOrientation?.();
this.applyTabRailWidth({ persist: false });
}, 100);
});
},
closeTabRailActionMenu(options = {}) {
const menu = document.querySelector('.tab-rail-action-menu');
const trigger = this._tabRailActionMenuTrigger;
menu?.remove();
if (this._tabRailActionMenuOutside) {
document.removeEventListener('pointerdown', this._tabRailActionMenuOutside, true);
this._tabRailActionMenuOutside = null;
}
if (this._tabRailActionMenuViewport) {
window.removeEventListener('resize', this._tabRailActionMenuViewport);
this._tabRailActionMenuViewport = null;
}
this._tabRailActionMenuTrigger = null;
if (options.restoreFocus) trigger?.focus?.();
},
openTabRailActionMenu(event, sessionId) {
event.preventDefault();
event.stopPropagation();
this.closeTabRailActionMenu();
const trigger = event.currentTarget;
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Session actions');
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
];
for (const action of actions) {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
button.textContent = action.label;
if (action.className) button.className = action.className;
button.addEventListener('click', () => {
this.closeTabRailActionMenu();
action.run();
});
menu.appendChild(button);
}
document.body.appendChild(menu);
const rect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(rect.right - menuRect.width, window.innerWidth - menuRect.width - 8))}px`;
menu.style.top = `${Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuRect.height - 8))}px`;
this._tabRailActionMenuTrigger = trigger;
this._tabRailActionMenuOutside = (pointerEvent) => {
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== trigger) this.closeTabRailActionMenu();
};
document.addEventListener('pointerdown', this._tabRailActionMenuOutside, true);
this._tabRailActionMenuViewport = () => this.closeTabRailActionMenu();
window.addEventListener('resize', this._tabRailActionMenuViewport, { once: true });
menu.addEventListener('keydown', (keyEvent) => {
const buttons = [...menu.querySelectorAll('button')];
const index = buttons.indexOf(document.activeElement);
if (keyEvent.key === 'Escape') {
keyEvent.preventDefault();
this.closeTabRailActionMenu({ restoreFocus: true });
} else if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
keyEvent.preventDefault();
const direction = keyEvent.key === 'ArrowDown' ? 1 : -1;
buttons[(index + direction + buttons.length) % buttons.length]?.focus();
}
});
menu.querySelector('button')?.focus();
},
});
+2 -1
View File
@@ -901,6 +901,7 @@ Object.assign(CodemanApp.prototype, {
const MIN_ROWS = 10;
const throttledResize = () => {
if (this._tabRailResizeOwnsObserver) return;
// Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens
// once after the user stops resizing. During active resize, the terminal
// stays at its old dimensions for up to 300ms.
@@ -2213,7 +2214,7 @@ Object.assign(CodemanApp.prototype, {
}
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/shell) + a LIVE pill.
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
badgeRow.className = 'history-item-badges';
if (s.mode) {
+1 -1
View File
@@ -11,7 +11,7 @@ export interface ResponseViewerTranscriptBlock {
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
// would drag node-pty and the whole session layer into this pure module, so the
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi']);
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok']);
function isPromptLine(line: string): boolean {
return /^\s*›\s*/.test(line);
+1
View File
@@ -26,3 +26,4 @@ export { registerAdminRoutes } from './admin-routes.js';
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';
+1 -1
View File
@@ -411,7 +411,7 @@ export function registerRalphRoutes(
writeFileSync(promptPath, fullPrompt, 'utf-8');
// Register session
ctx.addSession(session);
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
+81 -28
View File
@@ -24,6 +24,7 @@ import {
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
type GrokConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
@@ -47,7 +48,8 @@ import {
SessionWaitQuerySchema,
SessionWaitOutputQuerySchema,
} from '../schemas.js';
import { mergeSessionOrder } from '../../session-order.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import {
sessionWaits,
resolveWaitSignals,
@@ -107,7 +109,7 @@ import {
setHistoryIndexRefresher,
setHistorySessionIndex,
} from '../session-history-index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
@@ -331,21 +333,27 @@ export function _resetPasteRateBuckets(): void {
* session user could simply answer "yes" to in the terminal, so merely omitting
* `--approve` is not a clamp. Forcing `approveProjectTrust: false` makes
* buildPiCommand emit `--no-approve`, and the prompt never appears.
*
* Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove`
* (`--always-approve`), and an ABSENT config already spawns in grok's own
* ask-mode default, so only a sent config needs the flag forced off.
*/
async function clampExternalCliBypassForOwner(
owner: string | undefined,
codexConfig: CodexConfig | undefined,
geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined,
piConfig: PiConfig | undefined
piConfig: PiConfig | undefined,
grokConfig: GrokConfig | undefined
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined;
piConfig: PiConfig | undefined;
grokConfig: GrokConfig | undefined;
}> {
const granted = await canUsernameRunPrivilegedCommands(owner);
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig };
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig };
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
@@ -355,11 +363,13 @@ async function clampExternalCliBypassForOwner(
? { ...antigravityConfig, dangerouslySkipPermissions: false }
: antigravityConfig;
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
return {
codexConfig: clampedCodex,
geminiConfig: clampedGemini,
antigravityConfig: clampedAntigravity,
piConfig: clampedPi,
grokConfig: clampedGrok,
};
}
@@ -641,7 +651,7 @@ async function injectAgentSkill(casePath: string): Promise<void> {
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
): void {
// ═══════════════════════════════════════════════════════════════
// Auth
@@ -673,16 +683,23 @@ export function registerSessionRoutes(
return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner));
});
// ========== Session Tab Order (global sync, COD-131) ==========
// ========== Legacy Session Tab Order (temporary synchronized compatibility bridge) ==========
app.put('/api/session-order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
// Server is authoritative but never drops ids it knows about that the
// pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder).
const merged = mergeSessionOrder(order, ctx.store.getSessionOrder());
ctx.store.setSessionOrder(merged);
ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged });
return { success: true, data: { order: merged } };
app.put('/api/session-order', async (req, reply): Promise<ApiResponse<{ order: string[] }>> => {
try {
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
const user = getAuthUser(req);
const result = await ctx.tabLayouts.putLegacyOrder(
{ owner: ownerLayoutKey(ownerFor(req)), isAdmin: user.role === 'admin' },
order
);
return { success: true, data: { order: result.order } };
} catch (error) {
if (error instanceof TabLayoutValidationError) {
return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, error.message));
}
throw error;
}
});
// ========== Session Creation ==========
@@ -752,6 +769,7 @@ export function registerSessionRoutes(
body.mode !== 'gemini' &&
body.mode !== 'antigravity' &&
body.mode !== 'pi' &&
body.mode !== 'grok' &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -841,6 +859,12 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
if (body.mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
}
}
// Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing
@@ -886,9 +910,11 @@ export function registerSessionRoutes(
? body.antigravityConfig?.model
: mode === 'pi'
? body.piConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
: mode === 'grok'
? body.grokConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
@@ -898,12 +924,14 @@ export function registerSessionRoutes(
geminiConfig: gatedGeminiConfig,
antigravityConfig: gatedAntigravityConfig,
piConfig: gatedPiConfig,
grokConfig: gatedGrokConfig,
} = await clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig,
body.antigravityConfig,
body.piConfig
body.piConfig,
body.grokConfig
);
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
@@ -921,6 +949,7 @@ export function registerSessionRoutes(
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
@@ -930,7 +959,7 @@ export function registerSessionRoutes(
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
});
ctx.addSession(session);
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -1152,6 +1181,7 @@ export function registerSessionRoutes(
session.mode !== 'gemini' &&
session.mode !== 'antigravity' &&
session.mode !== 'pi' &&
session.mode !== 'grok' &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -2643,7 +2673,7 @@ export function registerSessionRoutes(
allowedTools: runClaudeModeConfig.allowedTools,
owner: runOwner,
});
ctx.addSession(session);
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -2686,6 +2716,7 @@ export function registerSessionRoutes(
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
envOverrides,
effort,
parentSessionId,
@@ -2734,6 +2765,7 @@ export function registerSessionRoutes(
geminiConfig ||
antigravityConfig ||
piConfig ||
grokConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2766,6 +2798,7 @@ export function registerSessionRoutes(
geminiConfig ||
antigravityConfig ||
piConfig ||
grokConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2868,6 +2901,14 @@ export function registerSessionRoutes(
}
}
// Check Grok availability if requested
if (mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
}
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -2914,8 +2955,15 @@ export function registerSessionRoutes(
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode, Codex, Gemini, and Antigravity use their own systems)
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity' && mode !== 'pi') {
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi and Grok use their own systems)
if (
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok'
) {
await writeHooksConfig(resolvedCasePath);
}
@@ -2987,6 +3035,7 @@ export function registerSessionRoutes(
mode !== 'gemini' &&
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
@@ -3009,9 +3058,11 @@ export function registerSessionRoutes(
? antigravityConfig?.model
: mode === 'pi'
? piConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
: mode === 'grok'
? grokConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
@@ -3020,7 +3071,8 @@ export function registerSessionRoutes(
geminiConfig: qsGatedGeminiConfig,
antigravityConfig: qsGatedAntigravityConfig,
piConfig: qsGatedPiConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig);
grokConfig: qsGatedGrokConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: resolvedCasePath,
@@ -3038,6 +3090,7 @@ export function registerSessionRoutes(
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
envOverrides,
effort,
remote,
@@ -3057,7 +3110,7 @@ export function registerSessionRoutes(
}
}
ctx.addSession(session);
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -3088,7 +3141,7 @@ export function registerSessionRoutes(
});
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
} else {
// 'claude', 'opencode', 'codex', 'gemini', and 'antigravity' modes use startInteractive()
// every non-shell mode ('claude', the external CLIs) uses startInteractive()
await session.startInteractive();
getLifecycleLog().log({
event: 'started',
+21 -4
View File
@@ -49,7 +49,7 @@ import {
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import { getRepositoryStatus } from '../repo-status.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
@@ -129,7 +129,7 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
): void {
const windowStatesPath = dataPath('subagent-window-states.json');
const parentMapPath = dataPath('subagent-parents.json');
@@ -380,7 +380,7 @@ export function registerSystemRoutes(
});
// ═══════════════════════════════════════════════════════════════
// CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity, Pi)
// CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity, Pi, Grok)
// ═══════════════════════════════════════════════════════════════
// ========== Claude ==========
@@ -446,6 +446,21 @@ export function registerSystemRoutes(
};
});
// ========== Grok ==========
// Carries `version` on top of the sibling shape, same reason as pi: `grok` is a
// binary name with known squatters, so the resolver version-probes candidates and
// this endpoint is where a misresolution shows up (path + version) instead of
// presenting as "the mode just doesn't work".
app.get('/api/grok/status', async () => {
const { isGrokAvailable, resolveGrokDir, getGrokCliVersion } = await import('../../utils/grok-cli-resolver.js');
return {
available: isGrokAvailable(),
path: resolveGrokDir(),
version: getGrokCliVersion(),
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
@@ -454,7 +469,9 @@ export function registerSystemRoutes(
app.post('/api/cleanup-state', async () => {
const activeSessionIds = new Set(ctx.sessions.keys());
const result = ctx.store.cleanupStaleSessions(activeSessionIds);
const result = await ctx.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
ctx.store.cleanupSessionsByIds(ids)
);
const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
+50
View File
@@ -0,0 +1,50 @@
/** @fileoverview Authenticated owner-scoped tab-layout read/write API. */
import type { FastifyInstance } from 'fastify';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { ownerFor } from '../route-helpers.js';
import type { TabLayoutPort } from '../ports/index.js';
export const TAB_LAYOUT_BODY_LIMIT = 128 * 1024;
function parseWriteBody(body: unknown): { baseVersion: number; layout: unknown } {
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
throw new TabLayoutValidationError('body must be an object');
}
const keys = Object.keys(body);
if (keys.length !== 2 || !Object.hasOwn(body, 'baseVersion') || !Object.hasOwn(body, 'layout')) {
throw new TabLayoutValidationError('body must contain exactly baseVersion and layout');
}
const input = body as { baseVersion?: unknown; layout?: unknown };
if (!Number.isSafeInteger(input.baseVersion) || (input.baseVersion as number) < 0 || input.layout === undefined) {
throw new TabLayoutValidationError('baseVersion must be a non-negative safe integer and layout is required');
}
return { baseVersion: input.baseVersion as number, layout: input.layout };
}
export function registerTabLayoutRoutes(app: FastifyInstance, ctx: TabLayoutPort): void {
app.get('/api/tab-layout', async (req) => ({
success: true,
data: { layout: await ctx.tabLayouts.get(ownerLayoutKey(ownerFor(req))) },
}));
app.put('/api/tab-layout', { bodyLimit: TAB_LAYOUT_BODY_LIMIT }, async (req, reply) => {
try {
const { baseVersion, layout } = parseWriteBody(req.body);
const result = await ctx.tabLayouts.put(ownerLayoutKey(ownerFor(req)), layout, baseVersion);
if (result.status === 'conflict') {
return reply.code(409).send({
...createErrorResponse(ApiErrorCode.CONFLICT, 'Tab layout version conflict'),
data: { layout: result.layout },
});
}
return { success: true, data: { layout: result.layout } };
} catch (error) {
if (error instanceof TabLayoutValidationError) {
return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, error.message));
}
throw error;
}
});
}
+30 -4
View File
@@ -53,7 +53,8 @@ import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js';
import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort } from '../ports/index.js';
import type { EventPort, TabLayoutPort } from '../ports/index.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import {
buildDownstreamResponseHeaders,
buildProxyCorsHeaders,
@@ -98,14 +99,14 @@ function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
return next;
}
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void {
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
registerCrudRoutes(app, ctx);
registerProxyRoutes(app);
}
// ───────────────────────────── CRUD ─────────────────────────────
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
app.get('/api/webviews', async (req) => {
const user = getAuthUser(req);
const all = await readWebviews(configDir());
@@ -145,6 +146,21 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`));
}
try {
await ctx.tabLayouts.webviewCreated(ownerLayoutKey(created.owner));
} catch (error) {
// The saved webview and its layout ref are one logical creation. If the
// layout rejects the new ref (for example at MAX_TAB_REFS), roll back the
// already-written JSON record and publish neither creation event.
await withWebviews(async (list) => {
const index = list.findIndex((webview) => webview.id === created.id);
if (index >= 0) {
list.splice(index, 1);
await writeWebviews(configDir(), list);
}
});
throw error;
}
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
return { success: true, data: created };
});
@@ -187,9 +203,19 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
const index = list.findIndex((w) => w.id === id);
if (index === -1) return 'not-found' as const;
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
const removed = list[index];
list.splice(index, 1);
await writeWebviews(configDir(), list);
return 'deleted' as const;
try {
await ctx.tabLayouts.webviewDeleted(ownerLayoutKey(removed.owner), id);
} catch (error) {
// Still inside withWebviews' mutex: restore the exact record at its
// original position without overwriting any concurrent mutation.
list.splice(index, 0, removed);
await writeWebviews(configDir(), list);
throw error;
}
return { status: 'deleted' as const, owner: removed.owner };
});
if (result === 'not-found') {
+48 -5
View File
@@ -122,7 +122,17 @@ export const FileWriteSchema = z
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_', 'PI_'];
const ALLOWED_ENV_PREFIXES = [
'CLAUDE_CODE_',
'OPENCODE_',
'CODEX_',
'GEMINI_',
'GOOGLE_',
'ANTIGRAVITY_',
'PI_',
'GROK_',
'XAI_',
];
/**
* Allowlisted exact env var keys (checked alongside the prefixes).
@@ -161,7 +171,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_* keys and CLAUDE_CONFIG_DIR are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_* keys and CLAUDE_CONFIG_DIR are allowed.',
}
);
@@ -300,6 +310,32 @@ const PiConfigSchema = z
})
.optional();
/**
* Schema for Grok Build CLI (xAI `grok`)-specific configuration.
*
* `alwaysApprove` maps to `--always-approve` (grok's bypassPermissions mode).
* An ABSENT config spawns bare `grok` = grok's own ask-mode default, so the
* multi-user clamp only needs the only-if-sent branch (like codex/antigravity).
*/
const GrokConfigSchema = z
.object({
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
alwaysApprove: z.boolean().optional(),
continueSession: z.boolean().optional(),
// Ids only: grok's --resume also matches session TITLES (arbitrary user
// strings), which this regex deliberately cannot express.
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._-]+$/)
.optional(),
})
.optional();
/**
* The session that spawned the one being created — pure UI decoration, drawn as a
* lineage line between the two tabs. Accepted here and, equivalently, as the
@@ -313,7 +349,7 @@ const parentSessionIdSchema = z.string().max(100).optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(),
name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
@@ -329,6 +365,7 @@ export const CreateSessionSchema = z.object({
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -464,6 +501,7 @@ const RemoteCommandOverridesSchema = z
gemini: z.string().min(1).max(300).optional(),
antigravity: z.string().min(1).max(300).optional(),
pi: z.string().min(1).max(300).optional(),
grok: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -738,12 +776,13 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -956,6 +995,8 @@ export const SettingsUpdateSchema = z
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
tabRailWidth: z.number().int().min(208).max(360).optional(),
/**
* Session list layout. Display key (per-device).
* 'header' = horizontal tab strip
@@ -967,6 +1008,8 @@ export const SettingsUpdateSchema = z
* on data-sidebar-detail. See applySessionListLayout() in app.js.
*/
sessionListLayout: z.enum(['header', 'sidebar', 'sidebar-rich']).optional(),
/** Session-name text size in vertical navigation. Display key (per-device). */
sessionSidebarFontSize: z.number().int().min(11).max(18).optional(),
agentTeamsEnabled: z.boolean().optional(),
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
claudeModel: z.string().max(50).optional(),
@@ -1267,7 +1310,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
+93 -25
View File
@@ -50,6 +50,9 @@ import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import { createMultiplexer } from '../mux-factory.js';
import { getStore } from '../state-store.js';
import { TabLayoutService } from '../tab-layout-service.js';
import { ownerLayoutKey } from '../tab-layout-persistence.js';
import { readWebviews } from '../webview-store.js';
import { extractCompletionPhrase } from '../ralph-config.js';
import { fileStreamManager } from '../file-stream-manager.js';
import {
@@ -81,6 +84,7 @@ import { applyWorkspaceHooks } from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push';
import { SseStreamManager } from './sse-stream-manager.js';
import { deriveTabLayoutSseHint } from './tab-layout-sse.js';
import {
type SessionListenerRefs,
createSessionListeners,
@@ -170,6 +174,7 @@ import {
registerWsRoutes,
registerVoiceRoutes,
registerWebviewRoutes,
registerTabLayoutRoutes,
tryWebviewRefererFallback,
} from './routes/index.js';
import { CronService } from '../cron/cron-service.js';
@@ -247,6 +252,7 @@ export class WebServer extends EventEmitter {
private cronService!: CronService;
private sse: SseStreamManager;
private store = getStore();
private tabLayouts!: TabLayoutService;
private port: number;
private host: string;
private https: boolean;
@@ -350,6 +356,17 @@ export class WebServer extends EventEmitter {
},
this.cleanup
);
this.tabLayouts = new TabLayoutService({
store: this.store,
sessions: this.sessions,
readWebviews: () => readWebviews(getDataDir()),
broadcast: this.broadcast.bind(this),
broadcastSessionOrder: (change) => {
this.cachedLightState = null;
this.sse.broadcastSessionOrder(change);
},
});
if (this.testMode) this.tabLayouts.markRestorationSkipped();
// Approvals Inbox → SSE. The singleton has no server reference; these
// callbacks are its only way out. Broadcasts carry sessionId, so the
@@ -595,6 +612,17 @@ export class WebServer extends EventEmitter {
}
}
/** Add a tentative session only after its owner layout accepts the creation. */
private async registerSessionWithLayout(session: Session): Promise<void> {
this.sessions.set(session.id, session);
try {
await this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner));
} catch (error) {
this.sessions.delete(session.id);
throw error;
}
}
/**
* Build a route context object satisfying all 5 port interfaces.
* Single object with zero runtime cost — ISP enforced at the type level.
@@ -605,9 +633,8 @@ export class WebServer extends EventEmitter {
return {
// SessionPort
sessions: this.sessions as ReadonlyMap<string, Session>,
addSession: (session: Session) => {
this.sessions.set(session.id, session);
},
addSession: this.registerSessionWithLayout.bind(this),
tabLayouts: this.tabLayouts,
cleanupSession: this.cleanupSession.bind(this),
setupSessionListeners: this.setupSessionListeners.bind(this),
persistSessionState: this.persistSessionState.bind(this),
@@ -991,6 +1018,7 @@ export class WebServer extends EventEmitter {
registerAdminRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx);
registerTabLayoutRoutes(this.app, ctx);
// Cron: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes.
@@ -1154,8 +1182,19 @@ export class WebServer extends EventEmitter {
}
}
private async _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void> {
private async _doCleanupSession(
sessionId: string,
killMux: boolean,
reason?: string,
coordinateLayout = true
): Promise<void> {
const session = this.sessions.get(sessionId);
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
if (coordinateLayout && session && killMux && !pinned) {
return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () =>
this._doCleanupSession(sessionId, killMux, reason, false)
);
}
const lifecycleLog = getLifecycleLog();
lifecycleLog.log({
event: killMux ? 'deleted' : 'detached',
@@ -1396,6 +1435,7 @@ export class WebServer extends EventEmitter {
{ isGeminiAvailable },
{ isAntigravityAvailable },
{ isPiAvailable },
{ isGrokAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
@@ -1405,6 +1445,7 @@ export class WebServer extends EventEmitter {
import('../utils/gemini-cli-resolver.js'),
import('../utils/antigravity-cli-resolver.js'),
import('../utils/pi-cli-resolver.js'),
import('../utils/grok-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'),
]);
@@ -1415,6 +1456,7 @@ export class WebServer extends EventEmitter {
gemini: isGeminiAvailable(),
antigravity: isAntigravityAvailable(),
pi: isPiAvailable(),
grok: isGrokAvailable(),
cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
// keep without git (issue #236), same reasoning as cloudflared above.
@@ -1857,7 +1899,7 @@ export class WebServer extends EventEmitter {
// mode) so the flag-off path stays byte-identical.
session = new Session({ workingDir: run.workingDir });
}
this.sessions.set(session.id, session);
await this.registerSessionWithLayout(session);
this.store.incrementSessionsCreated();
this.persistSessionState(session);
await this.setupSessionListeners(session);
@@ -1987,9 +2029,11 @@ export class WebServer extends EventEmitter {
* Called on startup and can be called via API endpoint.
* @returns Number of sessions cleaned up
*/
private cleanupStaleSessions(): number {
private async cleanupStaleSessions(): Promise<number> {
const activeSessionIds = new Set(this.sessions.keys());
const result = this.store.cleanupStaleSessions(activeSessionIds);
const result = await this.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
this.store.cleanupSessionsByIds(ids)
);
const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
@@ -2013,11 +2057,13 @@ export class WebServer extends EventEmitter {
/** Shallow-filter the light-state blob to what a non-admin user may see. */
private filterLightStateForUser(base: Record<string, unknown>, username: string): Record<string, unknown> {
const ownedIds = new Set<string>();
const authoritativeOwners = new Map<string, string | undefined>();
for (const [id, session] of Object.entries(this.store.getSessions())) authoritativeOwners.set(id, session.owner);
for (const [id, session] of this.sessions) authoritativeOwners.set(id, session.owner);
const ownedIds = new Set([...authoritativeOwners].filter(([, owner]) => owner === username).map(([id]) => id));
const ownedClaudeIds = new Set<string>();
for (const [id, s] of this.sessions) {
for (const s of this.sessions.values()) {
if (s.owner === username) {
ownedIds.add(id);
if (s.claudeSessionId) ownedClaudeIds.add(s.claudeSessionId);
}
}
@@ -2035,6 +2081,9 @@ export class WebServer extends EventEmitter {
const filtered: Record<string, unknown> = {
...base,
sessions,
sessionOrder: Array.isArray(base.sessionOrder)
? (base.sessionOrder as string[]).filter((id) => ownedIds.has(id))
: [],
respawnStatus,
scheduledRuns: [], // legacy ScheduledRun has no owner yet → admin-only
subagents: bySession(base.subagents, 'sessionId'),
@@ -2071,6 +2120,7 @@ export class WebServer extends EventEmitter {
const result = {
version: APP_VERSION,
sessions: this.getLightSessionsState(),
sessionOrder: this.store.getSessionOrder(),
scheduledRuns: Array.from(this.scheduledRuns.values()),
respawnStatus,
globalStats: this.store.getAggregateStats(activeSessionTokens),
@@ -2079,7 +2129,6 @@ export class WebServer extends EventEmitter {
timestamp: now,
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131)
};
this.cachedLightState = { data: result, timestamp: now };
@@ -2118,6 +2167,11 @@ export class WebServer extends EventEmitter {
) {
return { adminOnly: true };
}
// Layout payloads contain only trusted routing metadata. Route them to that
// exact owner plus admins, never by resolving a client-supplied ref.
if (event.startsWith('tab:')) {
return deriveTabLayoutSseHint(data);
}
// Session-scoped families: resolve the owner from the payload's session id.
const SESSION_PREFIXES = [
'session:',
@@ -2363,26 +2417,26 @@ export class WebServer extends EventEmitter {
// This prevents race conditions where clients connect before state is ready
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
if (!this.testMode) {
await this.restoreMuxSessions();
const restored = await this.restoreMuxSessions();
await this.finalizeRestoredState(restored);
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of
// THIS instance whose case is gone from docker-cases.json (best-effort, never
// touches another instance's containers). Runs after restore so containers
// still referenced by a restored session are preserved.
void import('../docker-hosts.js')
.then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE))
.then((reaped) => {
if (reaped.length > 0)
console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`);
})
.catch(() => {
/* best-effort — daemon may be absent */
});
if (restored) {
void import('../docker-hosts.js')
.then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE))
.then((reaped) => {
if (reaped.length > 0)
console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`);
})
.catch(() => {
/* best-effort — daemon may be absent */
});
}
}
// Clean up stale sessions from state file that don't have active mux sessions
this.cleanupStaleSessions();
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
// older than 7 days from each live session's .claude-images/ hourly.
if (!this.testMode) {
@@ -2618,7 +2672,7 @@ export class WebServer extends EventEmitter {
return false;
}
private async restoreMuxSessions(): Promise<void> {
private async restoreMuxSessions(): Promise<boolean> {
try {
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)
const { alive, dead, discovered } = await this.mux.reconcileSessions();
@@ -2683,6 +2737,7 @@ export class WebServer extends EventEmitter {
geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined,
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
@@ -2897,11 +2952,24 @@ export class WebServer extends EventEmitter {
if (dead.length > 0) {
console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`);
}
return true;
} catch (err) {
console.error('[Server] Failed to restore mux sessions:', err);
return false;
}
}
/** Unlock destructive reconciliation only after mux restoration fully succeeds. */
private async finalizeRestoredState(restored: boolean): Promise<void> {
if (!restored) {
this.tabLayouts.markRestorationFailed();
return;
}
this.tabLayouts.markRestorationComplete();
await this.cleanupStaleSessions();
await this.tabLayouts.reconcileAfterRestoration();
}
/**
* Install Codeman's hooks into the workspaces of the sessions just recovered.
*
+15
View File
@@ -0,0 +1,15 @@
/** @fileoverview Trusted per-recipient payload selection for legacy session-order invalidations. */
import type { SessionOrderProjectionChange } from '../tab-layout-service.js';
import type { AuthUser } from '../types.js';
export function sessionOrderPayloadFor(
identity: AuthUser | undefined,
change: SessionOrderProjectionChange
): { order: string[] } | undefined {
if (!identity || identity.role === 'admin') {
return change.globalChanged ? { order: [...change.globalOrder] } : undefined;
}
if (!Object.hasOwn(change.changedOwnerOrders, identity.username)) return undefined;
const order = change.changedOwnerOrders[identity.username];
return Array.isArray(order) ? { order: [...order] } : undefined;
}
+5 -2
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 155 event constants organized by category:
* 156 event constants organized by category:
* - **Core** (1): init
* - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
@@ -32,7 +32,7 @@
* - **Cases** (4): created, linked, deleted, order-changed
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
* - **Web tabs** (1): webview:changed
* - **Web tabs** (2): webview:changed, tab:layoutChanged
*
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
*
@@ -449,6 +449,8 @@ export const SessionOrderChanged = 'session:orderChanged' as const;
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
* re-fetches the list rather than patching from the payload. */
export const WebviewChanged = 'webview:changed' as const;
/** Owner-scoped layout invalidation. Payload contains only `{ owner, version }`. */
export const TabLayoutChanged = 'tab:layoutChanged' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
@@ -665,4 +667,5 @@ export const SseEvent = {
// Web tabs (dashboard URLs)
WebviewChanged,
TabLayoutChanged,
} as const;
+85 -26
View File
@@ -17,9 +17,11 @@
import type { FastifyReply } from 'fastify';
import type { BackgroundTask } from '../session.js';
import type { SessionOrderProjectionChange } from '../tab-layout-service.js';
import type { AuthUser } from '../types.js';
import { CleanupManager, StaleExpirationMap } from '../utils/index.js';
import { SseEvent } from './sse-events.js';
import { sessionOrderPayloadFor } from './session-order-sse.js';
import {
TERMINAL_BATCH_INTERVAL,
TASK_UPDATE_BATCH_INTERVAL,
@@ -34,6 +36,7 @@ import {
// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
// Pre-computed once at startup to avoid repeated string allocation.
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
const UNROUTED_TAB_LAYOUT = Symbol('unrouted-tab-layout');
/** Dependencies injected by WebServer — keeps SseStreamManager decoupled from session/respawn state. */
interface SseStreamManagerDeps {
@@ -77,6 +80,10 @@ export class SseStreamManager {
private remoteSseClients: Set<FastifyReply> = new Set();
/** Clients with backpressure — skip writes until 'drain' fires */
private backpressuredClients: Set<FastifyReply> = new Set();
/** Latest already recipient-filtered legacy order frame awaiting a client's drain. */
private pendingSessionOrderFrames: Map<FastifyReply, string> = new Map();
/** Latest owner-filtered tab-layout invalidation per affected owner awaiting a client's drain. */
private pendingTabLayoutFrames: Map<FastifyReply, Map<string | symbol, string>> = new Map();
// ─── Tunnel State ───────────────────────────────────────
/** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
@@ -144,10 +151,7 @@ export class SseStreamManager {
// If a previous reply registered the same id (reconnect), drop the old one.
const prev = this.sseClientsById.get(clientId);
if (prev && prev !== reply) {
this.sseClients.delete(prev);
this.remoteSseClients.delete(prev);
this.backpressuredClients.delete(prev);
this.sseClientIdentity.delete(prev);
this.removeClient(prev);
}
this.sseClientsById.set(clientId, reply);
}
@@ -157,6 +161,8 @@ export class SseStreamManager {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
this.pendingSessionOrderFrames.delete(reply);
this.pendingTabLayoutFrames.delete(reply);
this.sseClientIdentity.delete(reply);
// Clear any clientId mappings pointing at this reply
for (const [id, r] of this.sseClientsById) {
@@ -199,8 +205,7 @@ export class SseStreamManager {
try {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.removeClient(reply);
}
}
@@ -210,7 +215,44 @@ export class SseStreamManager {
try {
reply.raw.write(SSE_PADDING);
} catch {
/* client gone */
this.removeClient(reply);
}
}
private markBackpressured(reply: FastifyReply): void {
this.backpressuredClients.add(reply);
reply.raw.once('drain', () => this.flushBackpressuredClient(reply));
}
private flushBackpressuredClient(reply: FastifyReply): void {
if (!this.sseClients.has(reply)) return;
this.backpressuredClients.delete(reply);
try {
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
const recovered = reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
if (!recovered) {
this.markBackpressured(reply);
return;
}
const pendingLayouts = this.pendingTabLayoutFrames.get(reply);
if (pendingLayouts) {
for (const [owner, pendingLayout] of pendingLayouts) {
pendingLayouts.delete(owner);
this.sendSSEPreformatted(reply, pendingLayout);
if (!this.sseClients.has(reply)) return;
if (this.backpressuredClients.has(reply)) {
if (pendingLayouts.size === 0) this.pendingTabLayoutFrames.delete(reply);
return;
}
}
this.pendingTabLayoutFrames.delete(reply);
}
const pendingOrder = this.pendingSessionOrderFrames.get(reply);
if (!pendingOrder) return;
this.pendingSessionOrderFrames.delete(reply);
this.sendSSEPreformatted(reply, pendingOrder);
} catch {
this.removeClient(reply);
}
}
@@ -224,24 +266,11 @@ export class SseStreamManager {
try {
const ok = reply.raw.write(message);
if (!ok) {
// Buffer is full — mark as backpressured, resume on drain
this.backpressuredClients.add(reply);
reply.raw.once('drain', () => {
this.backpressuredClients.delete(reply);
// Client may have missed terminal data during backpressure.
// Tell it to reload the active session's buffer to recover.
try {
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
} catch {
/* client gone */
}
});
// Buffer is full — mark as backpressured, resume on drain.
this.markBackpressured(reply);
}
} catch {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
this.removeClient(reply);
}
}
@@ -276,6 +305,36 @@ export class SseStreamManager {
for (const [client] of this.sseClients) {
// Multi-user ownership routing (no-op for identity-less single-user clients).
if (!this.canDeliver(client, hint)) continue;
if (event === SseEvent.TabLayoutChanged && this.backpressuredClients.has(client)) {
const owner =
data !== null &&
typeof data === 'object' &&
Object.hasOwn(data, 'owner') &&
typeof (data as { owner?: unknown }).owner === 'string'
? (data as { owner: string }).owner
: (hint?.username ?? hint?.owner ?? UNROUTED_TAB_LAYOUT);
let pending = this.pendingTabLayoutFrames.get(client);
if (!pending) {
pending = new Map();
this.pendingTabLayoutFrames.set(client, pending);
}
pending.set(owner, message);
continue;
}
this.sendSSEPreformatted(client, message);
}
}
/** Dispatch the legacy order projection selected from each trusted client identity. */
broadcastSessionOrder(change: SessionOrderProjectionChange): void {
for (const [client] of this.sseClients) {
const payload = sessionOrderPayloadFor(this.sseClientIdentity.get(client), change);
if (!payload) continue;
const message = `event: ${SseEvent.SessionOrderChanged}\ndata: ${JSON.stringify(payload)}\n\n`;
if (this.backpressuredClients.has(client)) {
this.pendingSessionOrderFrames.set(client, message);
continue;
}
this.sendSSEPreformatted(client, message);
}
}
@@ -504,9 +563,7 @@ export class SseStreamManager {
// Remove dead clients
for (const client of deadClients) {
this.sseClients.delete(client);
this.remoteSseClients.delete(client);
this.backpressuredClients.delete(client);
this.removeClient(client);
}
if (deadClients.length > 0) {
@@ -553,6 +610,8 @@ export class SseStreamManager {
this.sseClients.clear();
this.remoteSseClients.clear();
this.backpressuredClients.clear();
this.pendingSessionOrderFrames.clear();
this.pendingTabLayoutFrames.clear();
// Clear per-session batch timers
for (const timer of this.terminalBatchTimers.values()) {
+6
View File
@@ -0,0 +1,6 @@
/** @fileoverview Trusted owner routing metadata for tab-layout invalidations. */
import type { SseRoutingHint } from './sse-stream-manager.js';
export function deriveTabLayoutSseHint(data: unknown): SseRoutingHint {
return { username: (data as { owner?: string }).owner, sessionScoped: true };
}
+40
View File
@@ -434,6 +434,46 @@ describe('CronService', () => {
});
describe('runNow', () => {
it('awaits layout insertion and stops lifecycle work when registration rejects', async () => {
const store = makeStore();
const sessions = new Map<string, FakeSession>();
let rejectRegistration!: (error: Error) => void;
const addSession = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectRegistration = reject;
})
);
const persistSessionState = vi.fn();
const setupSessionListeners = vi.fn(async () => {});
const service = new CronService({
store,
sessions,
addSession,
persistSessionState,
setupSessionListeners,
broadcast: vi.fn(),
getGlobalNiceConfig: vi.fn(async () => undefined),
getModelConfig: vi.fn(async () => null),
getClaudeModeConfig: vi.fn(async () => ({})),
getCheckpointDefaultEnabled: vi.fn(async () => true),
mux: { backend: 'tmux' },
} as unknown as CronDeps);
const job = service.createJob(mkInput({ enabled: false }));
const pending = service.runNow(job.id);
await vi.waitFor(() => expect(addSession).toHaveBeenCalledTimes(1));
expect(persistSessionState).not.toHaveBeenCalled();
expect(setupSessionListeners).not.toHaveBeenCalled();
rejectRegistration(new Error('layout capacity exceeded'));
const run = await pending;
expect(run!.status).toBe('failed');
expect(run!.errorMessage).toMatch(/layout capacity exceeded/);
expect(persistSessionState).not.toHaveBeenCalled();
expect(setupSessionListeners).not.toHaveBeenCalled();
});
it('launches regardless of enabled/schedule state', async () => {
const job = svc.service.createJob(mkInput({ enabled: false }));
const run = await svc.service.runNow(job.id);
+157
View File
@@ -0,0 +1,157 @@
/**
* @fileoverview File viewer detach button: pop the previewed file into a browser tab.
*
* The header used to end in [copy ⎘] [close ×], and for a PDF/media preview the
* copy button was completely dead: `filePreviewContent` stays empty for those
* branches, the `if (content)` guard swallowed the click, and the ⎘ glyph reads
* as a pop-out icon — so the visible symptom was "the detach button next to the
* X does nothing". There is now a real detach button (`filePreviewDetachBtn`)
* that opens the preview's own raw/preview route in a new tab, and the copy
* button toasts instead of silently doing nothing.
*
* What is pinned here:
* 1. opening a workspace PDF arms the detach URL (file-raw) and reveals the button,
* 2. an attachment docx/pptx detaches through the converted-PDF /preview route,
* other attachments through /raw,
* 3. detach opens the URL, severs opener, and closes the overlay,
* 4. a blocked pop-up (window.open → null) keeps the overlay up and toasts,
* 5. closing the preview disarms the button (no stale URL for the next file),
* 6. copy with no text buffer toasts instead of the old dead-button silence.
*
* Loaded via `vm` against a stub app, same harness style as
* file-preview-media.test.ts (no jsdom).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
function loadApp() {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
const windowStub: Record<string, unknown> = { addEventListener: vi.fn(), open: vi.fn() };
const context = vm.createContext({
CodemanApp,
console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: windowStub,
setTimeout,
clearTimeout,
confirm: () => true,
fetch: () => {
throw new Error('fetch not stubbed');
},
});
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const body = {
innerHTML: '',
querySelectorAll: () => [] as unknown[],
querySelector: () => null,
};
const overlay = {
classes: new Set<string>(['visible']),
classList: {
add: (c: string) => overlay.classes.add(c),
remove: (c: string) => overlay.classes.delete(c),
contains: (c: string) => overlay.classes.has(c),
},
};
const detachBtn = { hidden: true };
const elements: Record<string, unknown> = {
filePreviewBody: body,
filePreviewOverlay: overlay,
filePreviewTitle: { textContent: '' },
filePreviewFooter: { textContent: '' },
filePreviewDetachBtn: detachBtn,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const app = new CodemanApp() as Record<string, any>;
app.$ = (id: string) => elements[id] ?? null;
app._resetFilePreviewEdit = () => {};
app._isExternalPreviewPath = () => false;
app.showToast = vi.fn();
app.filePreviewContent = '';
return { app, body, overlay, detachBtn, windowStub };
}
describe('file viewer detach button', () => {
it('arms the detach URL and reveals the button for a workspace PDF', async () => {
const { app, detachBtn, body } = loadApp();
await app.openFilePreview('/ws/report.pdf', 's1');
expect(app.filePreviewDetachUrl).toBe('/api/sessions/s1/file-raw?path=%2Fws%2Freport.pdf');
expect(detachBtn.hidden).toBe(false);
expect(body.innerHTML).toContain('<iframe');
});
it('routes attachment office docs through /preview and other attachments through /raw', async () => {
const { app } = loadApp();
await app.openFilePreview('/tmp/deck.pptx', 's1', 'att-1');
expect(app.filePreviewDetachUrl).toBe('/api/sessions/s1/attachments/att-1/preview');
await app.openFilePreview('/tmp/scan.pdf', 's1', 'att-2');
expect(app.filePreviewDetachUrl).toBe('/api/sessions/s1/attachments/att-2/raw');
});
it('opens the URL, severs opener, and closes the overlay on detach', () => {
const { app, overlay, windowStub } = loadApp();
const win: Record<string, unknown> = { opener: {} };
(windowStub.open as ReturnType<typeof vi.fn>).mockReturnValue(win);
app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf';
app.detachFilePreview();
expect(windowStub.open).toHaveBeenCalledWith('/api/sessions/s1/file-raw?path=doc.pdf', '_blank');
expect(win.opener).toBeNull();
expect(overlay.classList.contains('visible')).toBe(false);
});
it('keeps the overlay and toasts when the pop-up is blocked', () => {
const { app, overlay, windowStub } = loadApp();
(windowStub.open as ReturnType<typeof vi.fn>).mockReturnValue(null);
app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf';
app.detachFilePreview();
expect(overlay.classList.contains('visible')).toBe(true);
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Pop-up blocked'), 'error');
});
it('does nothing when no preview is armed', () => {
const { app, windowStub } = loadApp();
app.filePreviewDetachUrl = '';
app.detachFilePreview();
expect(windowStub.open).not.toHaveBeenCalled();
});
it('disarms the button when the preview closes', () => {
const { app, detachBtn } = loadApp();
app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf';
detachBtn.hidden = false;
app.closeFilePreview();
expect(app.filePreviewDetachUrl).toBe('');
expect(detachBtn.hidden).toBe(true);
});
it('copy with no text buffer toasts instead of staying silent', () => {
const { app } = loadApp();
app.filePreviewContent = '';
app.copyFilePreviewContent();
expect(app.showToast).toHaveBeenCalledWith('Nothing to copy in this preview', 'info');
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* @fileoverview Tests for the Grok CLI resolver wrapper.
*
* Grok is the second resolver with a version probe: `grok` is a binary name
* with known squatters (the unrelated @vibe-kit/grok-cli npm package also
* installs a `grok` bin), so a resolved path is only accepted once
* `grok --version` prints a version-shaped string. The probe EXECUTES the
* candidate, which is exactly why it must never run under vitest: the
* hermeticity test below pins that gate with a real executable fixture, the
* same behavior-level pin test/pi-cli-resolver.test.ts carries.
*/
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createGrokResolverForTest, GROK_VERSION_REGEX } from '../src/utils/grok-cli-resolver.js';
import {
cliResolveRetryDelayMs,
createProductionCliResolverHost,
type CliResolverHost,
} from '../src/utils/cli-executable-resolver.js';
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createHost(
options: {
processPathResult?: string | null;
loginShellResults?: Array<string | null>;
existingPaths?: string[];
} = {}
): CliResolverHost {
const loginShellResults = [...(options.loginShellResults ?? [])];
const existingPaths = new Set(options.existingPaths ?? []);
return {
processPath: '/service/bin',
shellPath: '/bin/zsh',
shellArgs: ['-l'],
findOnProcessPath: () => options.processPathResult ?? null,
findInLoginShell: () => loginShellResults.shift() ?? null,
exists: (path) => existingPaths.has(path),
};
}
describe('Grok CLI resolver', () => {
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
const binaryPath = '/service/bin/grok';
const probe = vi.fn(() => '1.0.5');
const resolver = createGrokResolverForTest(
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
probe
);
expect(resolver.resolve()).toMatchObject({
binaryPath,
directory: '/service/bin',
source: 'process-path',
metadata: '1.0.5',
});
expect(probe).toHaveBeenCalledWith(binaryPath);
});
it('rejects a candidate the probe refuses and falls through to a later one', () => {
// An unrelated `grok` on the service PATH (probe returns null) must not
// mask the real coding agent found by the login shell.
const impostor = '/service/bin/grok';
const genuine = '/login-shell/bin/grok';
const probe = vi.fn((binPath: string) => (binPath === genuine ? '1.0.5' : null));
const resolver = createGrokResolverForTest(
createHost({
processPathResult: impostor,
loginShellResults: [genuine],
existingPaths: [impostor, genuine],
}),
probe
);
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '1.0.5' });
});
it('negative-caches a miss and retries only after the backoff elapses', () => {
const binaryPath = '/late/bin/grok';
let now = 0;
const probe = vi.fn(() => '1.0.5');
const resolver = createGrokResolverForTest(
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
probe,
() => now
);
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
expect(probe).not.toHaveBeenCalled();
now = cliResolveRetryDelayMs(1);
expect(resolver.resolve()?.metadata).toBe('1.0.5');
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
});
it('extracts the version from the real output shape (`grok 1.0.5 (5115b46bc9)`)', () => {
// GROK_VERSION_REGEX is shared with the dependency registry (doctor), so the
// shape it accepts is contract, not implementation detail.
expect(GROK_VERSION_REGEX.exec('grok 1.0.5 (5115b46bc9)')?.[1]).toBe('1.0.5');
expect(GROK_VERSION_REGEX.exec('1.0.5')?.[1]).toBe('1.0.5');
expect(GROK_VERSION_REGEX.exec('v1.0.5')).toBeNull();
expect(GROK_VERSION_REGEX.exec('not a version')).toBeNull();
});
it('never executes a grok candidate under vitest (the ambient probe is VITEST-gated)', () => {
// A REAL executable fixture that prints a valid version. If the guard in
// probeGrokVersion is ever removed, the probe runs this script, the
// resolution SUCCEEDS, and this test fails, pinning hermeticity by
// behavior rather than by source text.
const root = mkdtempSync(join(tmpdir(), 'codeman-grok-vitest-gate-'));
temporaryDirectories.push(root);
const binaryPath = join(root, 'grok');
writeFileSync(binaryPath, '#!/bin/sh\necho "grok 9.9.9 (deadbeef)"\n');
chmodSync(binaryPath, 0o755);
const hostOptions = {
processPath: root,
shellPath: '/bin/bash',
shellArgs: ['-i', '-l'] as string[],
runCommand: () => '',
isExecutableFile: (path: string) => path === binaryPath,
};
// Default (ambient) probe: the candidate is found but never executed, so
// the VITEST gate reports it unusable and resolution misses.
const gated = createGrokResolverForTest(createProductionCliResolverHost(hostOptions));
expect(gated.resolve()).toBeNull();
// Control: identical setup with an injected probe resolves, proving the
// null above comes from the gate, not from the fixture or the host.
const control = createGrokResolverForTest(createProductionCliResolverHost(hostOptions), () => '9.9.9');
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '9.9.9' });
});
});
+159
View File
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
describe('Grok mode schemas', () => {
it('accepts Grok session creation config', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'grok',
grokConfig: {
model: 'grok-4.5',
alwaysApprove: true,
},
});
expect(parsed.mode).toBe('grok');
expect(parsed.grokConfig).toEqual({
model: 'grok-4.5',
alwaysApprove: true,
});
});
it('accepts Grok quick-start config', () => {
const parsed = QuickStartSchema.parse({
caseName: 'grok-case',
mode: 'grok',
grokConfig: { resumeSessionId: '0198f2b4-aa10-7def-8123-4c5d6e7f8a9b', continueSession: true },
});
expect(parsed.mode).toBe('grok');
expect(parsed.grokConfig?.resumeSessionId).toBe('0198f2b4-aa10-7def-8123-4c5d6e7f8a9b');
});
it('rejects unsafe Grok model strings', () => {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'grok',
grokConfig: { model: 'grok; rm -rf /' },
})
).toThrow();
});
it('rejects unsafe Grok resumeSessionId values (ids only, never titles or paths)', () => {
// grok's own --resume also matches session TITLES, which are arbitrary user
// strings; the id regex is what keeps those (and paths) off the spawn line.
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'grok',
grokConfig: { resumeSessionId: '../../etc/passwd' },
})
).toThrow();
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'grok',
grokConfig: { resumeSessionId: 'my session title' },
})
).toThrow();
});
it('allows GROK_* and XAI_* env overrides but NOT bare provider keys', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'grok',
envOverrides: { GROK_HOME: '/tmp/grok-home', XAI_API_KEY: 'xai-test' },
});
expect(parsed.envOverrides).toEqual({ GROK_HOME: '/tmp/grok-home', XAI_API_KEY: 'xai-test' });
// XAI_* is xAI's own namespace (grok's documented auth var), the same
// narrow-vendor-namespace reasoning that admitted GOOGLE_* for gemini.
// Foreign provider keys stay out.
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'grok',
envOverrides: { ANTHROPIC_API_KEY: 'sk-test' },
})
).toThrow();
});
});
describe('Grok spawn command', () => {
it('builds a bare grok command when no config is sent (ask-mode default)', () => {
const cmd = buildSpawnCommand({ mode: 'grok', sessionId: 'abc12345' });
expect(cmd).toBe('grok');
});
it('maps alwaysApprove and model to flags', () => {
const cmd = buildSpawnCommand({
mode: 'grok',
sessionId: 'abc12345',
grokConfig: { alwaysApprove: true, model: 'grok-4.5' },
});
expect(cmd).toBe('grok --always-approve --model grok-4.5');
});
it('omits --always-approve when false or absent (grok defaults safe on its own)', () => {
expect(buildSpawnCommand({ mode: 'grok', sessionId: 'a', grokConfig: { alwaysApprove: false } })).toBe('grok');
expect(buildSpawnCommand({ mode: 'grok', sessionId: 'a', grokConfig: {} })).toBe('grok');
});
it('passes --resume for resume and skips --continue when both are present', () => {
expect(buildSpawnCommand({ mode: 'grok', sessionId: 'a', grokConfig: { resumeSessionId: '0198f2b4' } })).toBe(
'grok --resume 0198f2b4'
);
expect(buildSpawnCommand({ mode: 'grok', sessionId: 'a', grokConfig: { continueSession: true } })).toBe(
'grok --continue'
);
// The two conflict upstream: a valid explicit session id wins.
expect(
buildSpawnCommand({
mode: 'grok',
sessionId: 'a',
grokConfig: { continueSession: true, resumeSessionId: '0198f2b4' },
})
).toBe('grok --resume 0198f2b4');
});
it('drops unsafe values rather than escaping them (the result lands in `bash -c "..."`)', () => {
expect(buildSpawnCommand({ mode: 'grok', sessionId: 'a', grokConfig: { model: 'a`b' } })).toBe('grok');
expect(buildSpawnCommand({ mode: 'grok', sessionId: 'a', grokConfig: { resumeSessionId: 'x; rm -rf /' } })).toBe(
'grok'
);
});
it('never puts a secret-shaped flag on the spawn line (XAI_API_KEY flows via tmux setenv)', () => {
const cmd = buildSpawnCommand({
mode: 'grok',
sessionId: 'a',
grokConfig: { model: 'grok-4.5', alwaysApprove: true },
});
expect(cmd).not.toContain('key');
expect(cmd).not.toContain('token');
});
});
describe('Grok mode gates', () => {
it('is an external CLI mode (readiness/ralph/respawn gating)', () => {
expect(isExternalCliMode('grok')).toBe(true);
});
it('is NOT an alt-screen strip mode (fullscreen alt-screen TUI with mouse support)', () => {
expect(isAltScreenStripMode('grok')).toBe(false);
});
it('has docker/remote default commands', () => {
expect(defaultDockerCommandForMode('grok')).toBe('exec grok');
// Routed through an interactive login shell so ~/.grok/bin resolves,
// the same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
expect(defaultRemoteCommandForMode('grok')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'grok\'');
});
});
+15 -1
View File
@@ -40,13 +40,16 @@ function fakeElement(): any {
* point: if that reuse ever breaks, these tests stop loading rather than
* quietly testing a divergent copy.
*/
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512, tabOrientation = 'horizontal') {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
console,
window: { innerWidth },
document: {
documentElement: {
getAttribute: (name: string) => (name === 'data-tab-orientation' ? tabOrientation : null),
},
getElementById: () => null,
createElement: () => fakeElement(),
createElementNS: () => fakeElement(),
@@ -198,6 +201,11 @@ describe('home sessions column: gate', () => {
expect(app.shouldShowHomeSessions()).toBe(true);
});
it('yields to the persistent rail when the effective tab orientation is vertical', () => {
expect(loadHomeSessionsApp({}, 1512, 'vertical').shouldShowHomeSessions()).toBe(false);
expect(loadHomeSessionsApp({}, 1512, 'horizontal').shouldShowHomeSessions()).toBe(true);
});
it('stays out of a window too narrow to hold it beside the centered content', () => {
// Absolutely positioned: below the gate it would overlap the search panel
// rather than push it aside.
@@ -238,6 +246,12 @@ describe('home sessions column: wiring', () => {
expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/);
});
it('has a CSS backstop that suppresses the homepage rail in vertical mode', () => {
expect(css).toMatch(
/html\[data-tab-orientation='vertical'\]\s+\.home-sessions\s*\{\s*display:\s*none\s*!important;/
);
});
it('reuses the tab-load spinner rather than declaring a second one', () => {
// The working ring is the same motion a tab shows while it loads, on both
// home screens. Re-declaring the keyframes here is how they drift apart.
+180 -1
View File
@@ -6,8 +6,10 @@
* These behaviors live in server.ts (preSerialization hook, setNotFoundHandler),
* which the route-test harness does not install — so they need a real WebServer.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { flattenOwnerSessionOrder, type TabLayout } from '../src/tab-layout.js';
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
const PORT = 3168;
@@ -33,6 +35,183 @@ describe('Stable HTTP contract (live server)', () => {
expect(body.data.version).toBeDefined();
});
it('preserves the legacy global session order in single-user light state without exposing layouts', async () => {
const res = await fetch(`${base}/api/status`);
const body = await res.json();
expect(body.data.sessionOrder).toEqual([]);
expect(body.data).not.toHaveProperty('tabLayouts');
});
it('filters status order for regular users while admins and single-user mode retain the global projection', () => {
type FakeSession = {
id: string;
owner: string;
inputTokens: number;
outputTokens: number;
totalCost: number;
toLightDetailedState(): { id: string; owner: string };
};
type StatusInternals = {
sessions: Map<string, FakeSession>;
store: { getSessionOrder(): string[]; setSessionOrder(order: string[]): void };
cachedLightState: unknown;
cachedSessionsList: unknown;
getLightState(identity?: { username: string; role: 'admin' | 'user' }): Record<string, unknown>;
};
const internals = server as unknown as StatusInternals;
const previousOrder = internals.store.getSessionOrder();
const previousSessions = new Map(internals.sessions);
const fakeSession = (id: string, owner: string): FakeSession => ({
id,
owner,
inputTokens: 0,
outputTokens: 0,
totalCost: 0,
toLightDetailedState: () => ({ id, owner }),
});
try {
internals.sessions.clear();
internals.sessions.set('a1', fakeSession('a1', 'alice'));
internals.sessions.set('b1', fakeSession('b1', 'bob'));
internals.sessions.set('a2', fakeSession('a2', 'alice'));
internals.store.setSessionOrder(['b1', 'a1', 'a2']);
internals.cachedLightState = null;
internals.cachedSessionsList = null;
vi.stubEnv('CODEMAN_MULTIUSER', '1');
expect(internals.getLightState({ username: 'alice', role: 'user' }).sessionOrder).toEqual(['a1', 'a2']);
expect(internals.getLightState({ username: 'root', role: 'admin' }).sessionOrder).toEqual(['b1', 'a1', 'a2']);
vi.stubEnv('CODEMAN_MULTIUSER', '0');
expect(internals.getLightState().sessionOrder).toEqual(['b1', 'a1', 'a2']);
} finally {
vi.unstubAllEnvs();
internals.sessions.clear();
for (const [id, session] of previousSessions) internals.sessions.set(id, session);
internals.store.setSessionOrder(previousOrder);
internals.cachedLightState = null;
internals.cachedSessionsList = null;
}
});
it('keeps the tab layout foundation smoke contract atomic and writable', async () => {
type TabLayoutInternals = {
sessions: Map<string, { id: string; createdAt: number; owner?: string }>;
store: {
getState(): { sessionOrder?: string[]; tabLayouts?: Record<string, TabLayout> };
getSessionOrder(): string[];
getTabLayout(owner: string): TabLayout | null;
getTabLayouts(): Record<string, TabLayout>;
commitTabLayoutProjection: (...args: unknown[]) => unknown;
save(): void;
};
sse: {
addClient(reply: unknown, sessionFilter: Set<string> | null, isRemote: boolean): void;
removeClient(reply: unknown): void;
broadcast: (...args: unknown[]) => void;
broadcastSessionOrder: (...args: unknown[]) => void;
};
};
const internals = server as unknown as TabLayoutInternals;
const commit = vi.spyOn(internals.store, 'commitTabLayoutProjection');
const layoutEvent = vi.spyOn(internals.sse, 'broadcast');
const orderEvent = vi.spyOn(internals.sse, 'broadcastSessionOrder');
const previousSessions = new Map(internals.sessions);
const storeState = internals.store.getState();
const previousSessionOrder = storeState.sessionOrder ? [...storeState.sessionOrder] : undefined;
const previousTabLayouts = storeState.tabLayouts ? structuredClone(storeState.tabLayouts) : undefined;
const recipientWrites: string[] = [];
const recipient = { raw: { write: (chunk: string) => (recipientWrites.push(chunk), true) } };
internals.sse.addClient(recipient, null, false);
const requested = {
version: 0,
groups: [],
ungrouped: [],
updatedAt: '2026-08-23T00:00:00.000Z',
};
try {
expect(internals.store.getTabLayout('@single')).toBeNull();
const direct = await fetch(`${base}/api/tab-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion: 0, layout: requested }),
});
expect(direct.status).toBe(200);
expect((await direct.json()).data.layout.version).toBe(1);
expect(commit).toHaveBeenCalledTimes(1);
expect(layoutEvent).toHaveBeenCalledTimes(1);
expect(layoutEvent).toHaveBeenCalledWith(SseEvent.TabLayoutChanged, { owner: '@single', version: 1 }, undefined);
expect(orderEvent).not.toHaveBeenCalled();
expect(recipientWrites).toEqual(['event: tab:layoutChanged\ndata: {"owner":"@single","version":1}\n\n']);
recipientWrites.length = 0;
const layoutBeforeConflict = internals.store.getTabLayout('@single');
const orderBeforeConflict = internals.store.getSessionOrder();
const writesBeforeConflict = commit.mock.calls.length;
const layoutEventsBeforeConflict = layoutEvent.mock.calls.length;
const orderEventsBeforeConflict = orderEvent.mock.calls.length;
const stale = await fetch(`${base}/api/tab-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion: 0, layout: requested }),
});
expect(stale.status).toBe(409);
expect((await stale.json()).errorCode).toBe('CONFLICT');
expect(internals.store.getTabLayout('@single')).toEqual(layoutBeforeConflict);
expect(internals.store.getSessionOrder()).toEqual(orderBeforeConflict);
expect(commit).toHaveBeenCalledTimes(writesBeforeConflict);
expect(layoutEvent).toHaveBeenCalledTimes(layoutEventsBeforeConflict);
expect(orderEvent).toHaveBeenCalledTimes(orderEventsBeforeConflict);
expect(recipientWrites).toEqual([]);
const ownerGet = await fetch(`${base}/api/tab-layout`);
expect(ownerGet.status).toBe(200);
expect((await ownerGet.json()).data.layout.version).toBe(1);
const firstId = 'tab-layout-smoke-a';
const secondId = 'tab-layout-smoke-b';
internals.sessions.set(firstId, { id: firstId, createdAt: 1 });
internals.sessions.set(secondId, { id: secondId, createdAt: 2 });
const orderEventsBeforeLegacy = orderEvent.mock.calls.length;
const legacyPut = await fetch(`${base}/api/session-order`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: [secondId, firstId] }),
});
expect(legacyPut.status).toBe(200);
expect((await legacyPut.json()).data.order).toEqual([secondId, firstId]);
expect(internals.store.getSessionOrder()).toEqual([secondId, firstId]);
expect(flattenOwnerSessionOrder(internals.store.getTabLayout('@single')!)).toEqual([secondId, firstId]);
expect(orderEvent).toHaveBeenCalledTimes(orderEventsBeforeLegacy + 1);
expect(orderEvent).toHaveBeenLastCalledWith({
changedOwnerOrders: { '@single': [secondId, firstId] },
globalOrder: [secondId, firstId],
globalChanged: true,
});
expect(recipientWrites.at(-1)).toBe(
`event: session:orderChanged\ndata: {"order":["${secondId}","${firstId}"]}\n\n`
);
} finally {
internals.sse.removeClient(recipient);
internals.sessions.clear();
for (const [id, session] of previousSessions) internals.sessions.set(id, session);
if (previousSessionOrder) storeState.sessionOrder = [...previousSessionOrder];
else delete storeState.sessionOrder;
if (previousTabLayouts) storeState.tabLayouts = structuredClone(previousTabLayouts);
else delete storeState.tabLayouts;
internals.store.save();
commit.mockRestore();
layoutEvent.mockRestore();
orderEvent.mockRestore();
}
expect(internals.store.getSessionOrder()).toEqual(previousSessionOrder ?? []);
expect(internals.store.getTabLayouts()).toEqual(previousTabLayouts ?? {});
});
it('serves the same envelope on the /api/v1 alias', async () => {
const res = await fetch(`${base}/api/v1/status`);
expect(res.status).toBe(200);
+86 -2
View File
@@ -186,6 +186,9 @@ describe('Inline rename input', () => {
renameActiveAfter: !!app._activeRename,
sessionGone: !app.sessions.has('ghost-id'),
fetchFired,
renameClassActive:
document.querySelector('.tab-name[data-session-id="ghost-id"]')?.classList.contains('tab-name-renaming') ??
false,
};
});
@@ -194,6 +197,7 @@ describe('Inline rename input', () => {
expect(result.sessionGone).toBe(true);
// Cancel path skips the API call — deleting a session shouldn't trigger a stale rename PUT.
expect(result.fetchFired).toBe(false);
expect(result.renameClassActive).toBe(false);
});
it('Ghost tab: _cleanupSessionData for a DIFFERENT session does NOT cancel rename', async () => {
@@ -386,10 +390,16 @@ describe('Inline rename input', () => {
await new Promise((r) => setTimeout(r, 60));
window.fetch = origFetch;
return { mapName: app.sessions.get('no-sse')?.name ?? null };
return {
mapName: app.sessions.get('no-sse')?.name ?? null,
renameClassActive:
document.querySelector('.tab-name[data-session-id="no-sse"]')?.classList.contains('tab-name-renaming') ??
false,
};
});
expect(result.mapName).toBe('w9-case: fresh');
expect(result.renameClassActive).toBe(false);
});
it('A rejected rename restores the old label and leaves app.sessions untouched', async () => {
@@ -426,12 +436,16 @@ describe('Inline rename input', () => {
return {
mapName: app.sessions.get('rename-500')?.name ?? null,
label: document.querySelector('.tab-name[data-session-id="rename-500"]')?.textContent ?? null,
renameClassActive:
document.querySelector('.tab-name[data-session-id="rename-500"]')?.classList.contains('tab-name-renaming') ??
false,
toasts,
};
});
expect(result.mapName).toBe('w9-case');
expect(result.label).toBe('w9-case');
expect(result.renameClassActive).toBe(false);
expect(result.toasts).toContain('Failed to rename');
});
@@ -445,6 +459,7 @@ describe('Inline rename input', () => {
app: {
_activeRename: { sessionId: string } | null;
sessions: Map<string, { id: string; name: string }>;
renderSessionTabs: () => void;
startInlineRename: (id: string) => void;
};
}
@@ -461,11 +476,80 @@ describe('Inline rename input', () => {
tabName.textContent = 'Second';
wrap.appendChild(tabName);
document.body.appendChild(wrap);
// Cancelling the first rename is allowed to repaint the tab list. Model
// that synchronously so a target captured before cancel() becomes stale.
const originalRenderSessionTabs = app.renderSessionTabs;
app.renderSessionTabs = () => {
const current = document.querySelector('.tab-name[data-session-id="second-id"]');
current?.replaceWith(current.cloneNode(true));
};
app.startInlineRename('second-id');
return { firstActive, secondActive: app._activeRename?.sessionId };
app.renderSessionTabs = originalRenderSessionTabs;
return {
firstActive,
secondActive: app._activeRename?.sessionId,
secondInputVisible: !!document.querySelector('.tab-name[data-session-id="second-id"] input.tab-rename-input'),
firstRenameClassActive:
document.querySelector('.tab-name[data-session-id="first-id"]')?.classList.contains('tab-name-renaming') ??
false,
};
});
expect(result.firstActive).toBe('first-id');
expect(result.secondActive).toBe('second-id');
expect(result.secondInputVisible).toBe(true);
expect(result.firstRenameClassActive).toBe(false);
});
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
await resetState();
const id = 'vertical-live-input';
await page.evaluate((sessionId) => {
const app = (
window as unknown as {
app: {
sessions: Map<string, { id: string; name: string }>;
startInlineRename: (id: string) => void;
};
}
).app;
document.documentElement.dataset.tabOrientation = 'vertical';
const rail = document.getElementById('tabRail') as HTMLElement;
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
tab.className = 'session-tab';
tab.innerHTML =
`<span class="tab-name" data-session-id="${sessionId}">` +
'<span class="tab-name-prefix">w9-case: </span>old</span>';
rail.appendChild(tab);
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
app.startInlineRename(sessionId);
}, id);
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
const input = label.locator('input.tab-rename-input');
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await page.keyboard.type('edited title');
expect(await input.inputValue()).toBe('edited title');
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
const settled = await page.evaluate((sessionId) => {
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
app._activeRename?.cancel();
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
return {
classActive: label.classList.contains('tab-name-renaming'),
inputPresent: !!label.querySelector('input.tab-rename-input'),
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
};
}, id);
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
});
});
+2 -2
View File
@@ -190,7 +190,7 @@ describe('_updateLocalEchoState mode gating', () => {
expect(app._localEchoEnabled).toBe(false);
});
it.each(['claude', 'gemini', 'opencode', 'pi'])('keeps the overlay enabled for %s sessions', (mode) => {
it.each(['claude', 'gemini', 'opencode', 'pi', 'grok'])('keeps the overlay enabled for %s sessions', (mode) => {
const overlay = makeOverlay();
const app = makeApp(mode, overlay);
app._updateLocalEchoState();
@@ -373,7 +373,7 @@ describe('_updateLocalEchoState echo policy', () => {
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
});
it.each(['claude', 'gemini', 'opencode', 'pi'])(
it.each(['claude', 'gemini', 'opencode', 'pi', 'grok'])(
"%s -> policy 'buffer' + overlay enabled (existing behavior)",
(mode) => {
const overlay = makeOverlay();
+1 -1
View File
@@ -424,7 +424,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
isCliAvailable: () => true,
});
const menu = app._buildMobileOverviewRunMenu();
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'shell']);
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']);
});
it('gates every mode the picker actually offers', () => {
+23 -1
View File
@@ -33,9 +33,30 @@ export function createMockRouteContext(options?: {
return {
// -- SessionPort --
sessions,
addSession: vi.fn((s: MockSession) => {
addSession: vi.fn(async (s: MockSession) => {
sessions.set(s.id, s);
}),
tabLayouts: {
get: vi.fn(),
put: vi.fn(),
putLegacyOrder: vi.fn(async (_actor: unknown, order: readonly string[]) => ({
order: [...order],
changedOwnerOrders: {},
globalOrder: [...order],
globalChanged: false,
})),
sessionCreated: vi.fn(),
webviewCreated: vi.fn(),
sessionsRemoved: vi.fn(async () => {}),
webviewDeleted: vi.fn(async () => {}),
markRestorationComplete: vi.fn(),
markRestorationFailed: vi.fn(),
markRestorationSkipped: vi.fn(),
assertDeletionReady: vi.fn(),
runSessionDeletion: vi.fn(async (_removed, action) => action()),
runStaleSessionCleanup: vi.fn(async (_activeIds, action) => action(new Set())),
reconcileAfterRestoration: vi.fn(async () => {}),
},
cleanupSession: vi.fn(async () => {}),
setupSessionListeners: vi.fn(async () => {}),
persistSessionState: vi.fn(),
@@ -82,6 +103,7 @@ export function createMockRouteContext(options?: {
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
getDailyStats: vi.fn(() => []),
cleanupStaleSessions: vi.fn(() => ({ count: 0, cleaned: [] })),
cleanupSessionsByIds: vi.fn(() => ({ count: 0, cleaned: [] })),
},
port: 3000,
https: false,
+9
View File
@@ -18,6 +18,7 @@ import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js';
import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
@@ -49,6 +50,11 @@ vi.mock('../src/utils/pi-cli-resolver.js', () => ({
resolvePiDir: vi.fn(() => null),
getPiCliVersion: vi.fn(() => null),
}));
vi.mock('../src/utils/grok-cli-resolver.js', () => ({
isGrokAvailable: vi.fn(() => false),
resolveGrokDir: vi.fn(() => null),
getGrokCliVersion: vi.fn(() => null),
}));
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null),
@@ -138,6 +144,7 @@ describe('WebServer.renderIndexHtml', () => {
vi.mocked(isGeminiAvailable).mockReturnValue(false);
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
vi.mocked(isPiAvailable).mockReturnValue(true);
vi.mocked(isGrokAvailable).mockReturnValue(false);
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
vi.mocked(isGitAvailable).mockReturnValue(true);
const { server } = makeServer({});
@@ -152,6 +159,7 @@ describe('WebServer.renderIndexHtml', () => {
gemini: false,
antigravity: false,
pi: true,
grok: false,
cloudflared: true,
git: true,
});
@@ -167,6 +175,7 @@ describe('WebServer.renderIndexHtml', () => {
isGeminiAvailable,
isAntigravityAvailable,
isPiAvailable,
isGrokAvailable,
isCloudflaredAvailable,
isGitAvailable,
]) {
+48 -21
View File
@@ -5,7 +5,7 @@
* `POST /api/quick-start` and, until pi was added, had no tests at all.
*
* The helper has two shapes and the difference is the whole point:
* - only-if-sent (codex, antigravity): an ABSENT config already spawns safe, so
* - only-if-sent (codex, antigravity, grok): an ABSENT config already spawns safe, so
* only a sent config needs its flag forced off.
* - MATERIALIZE (gemini, pi): the absent-config default is itself unsafe for a
* non-granted owner (gemini's builder defaults to `yolo`; pi's default is an
@@ -25,20 +25,23 @@ describe('clampExternalCliBypassForOwner — single-user mode', () => {
{ dangerouslyBypassApprovals: true },
{ approvalMode: 'yolo' },
{ dangerouslySkipPermissions: true },
{ approveProjectTrust: true }
{ approveProjectTrust: true },
{ alwaysApprove: true }
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: true });
expect(out.geminiConfig).toEqual({ approvalMode: 'yolo' });
expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: true });
expect(out.piConfig).toEqual({ approveProjectTrust: true });
expect(out.grokConfig).toEqual({ alwaysApprove: true });
});
it('leaves absent configs absent', async () => {
const out = await _clampExternalCliBypassForOwner(undefined, undefined, undefined, undefined, undefined);
const out = await _clampExternalCliBypassForOwner(undefined, undefined, undefined, undefined, undefined, undefined);
expect(out.codexConfig).toBeUndefined();
expect(out.geminiConfig).toBeUndefined();
expect(out.antigravityConfig).toBeUndefined();
expect(out.piConfig).toBeUndefined();
expect(out.grokConfig).toBeUndefined();
});
});
@@ -64,57 +67,75 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
{ dangerouslyBypassApprovals: true },
undefined,
{ dangerouslySkipPermissions: true },
{ approveProjectTrust: true }
{ approveProjectTrust: true },
{ alwaysApprove: true }
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: true });
expect(out.geminiConfig).toBeUndefined();
expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: true });
expect(out.piConfig).toEqual({ approveProjectTrust: true });
expect(out.grokConfig).toEqual({ alwaysApprove: true });
});
it('passes through for a user holding the bypass grant', async () => {
const out = await _clampExternalCliBypassForOwner('trusted', undefined, undefined, undefined, {
approveProjectTrust: true,
});
const out = await _clampExternalCliBypassForOwner(
'trusted',
undefined,
undefined,
undefined,
{ approveProjectTrust: true },
{ alwaysApprove: true }
);
expect(out.piConfig).toEqual({ approveProjectTrust: true });
expect(out.grokConfig).toEqual({ alwaysApprove: true });
});
it('forces codex/antigravity bypass off for a non-granted owner (only-if-sent branch)', async () => {
it('forces codex/antigravity/grok bypass off for a non-granted owner (only-if-sent branch)', async () => {
const out = await _clampExternalCliBypassForOwner(
'peon',
{ dangerouslyBypassApprovals: true, model: 'gpt-5' },
undefined,
{ dangerouslySkipPermissions: true, model: 'gemini-3-pro' },
undefined
undefined,
{ alwaysApprove: true, model: 'grok-4.5' }
);
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, model: 'gpt-5' });
expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: false, model: 'gemini-3-pro' });
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
});
it('leaves codex/antigravity absent when nothing was sent (they already spawn safe)', async () => {
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined);
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();
expect(out.antigravityConfig).toBeUndefined();
expect(out.grokConfig).toBeUndefined();
});
it('MATERIALIZES gemini to auto_edit even when no config was sent', async () => {
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined);
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
expect(out.geminiConfig).toEqual({ approvalMode: 'auto_edit' });
});
it('MATERIALIZES pi to --no-approve even when no config was sent', async () => {
// The load-bearing case: omitting --approve is NOT a clamp for pi, because
// pi's own default is to ASK, and the session user can answer that prompt.
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined);
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
expect(out.piConfig).toEqual({ approveProjectTrust: false });
});
it('forces a sent pi approveProjectTrust:true down to false, keeping other fields', async () => {
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, {
approveProjectTrust: true,
model: 'sonnet:high',
provider: 'anthropic',
});
const out = await _clampExternalCliBypassForOwner(
'peon',
undefined,
undefined,
undefined,
{
approveProjectTrust: true,
model: 'sonnet:high',
provider: 'anthropic',
},
undefined
);
expect(out.piConfig).toEqual({
approveProjectTrust: false,
model: 'sonnet:high',
@@ -123,10 +144,16 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
});
it('fails closed for an unknown/deleted owner', async () => {
const out = await _clampExternalCliBypassForOwner('ghost', undefined, undefined, undefined, {
approveProjectTrust: true,
});
const out = await _clampExternalCliBypassForOwner(
'ghost',
undefined,
undefined,
undefined,
{ approveProjectTrust: true },
{ alwaysApprove: true }
);
expect(out.piConfig).toEqual({ approveProjectTrust: false });
expect(out.geminiConfig).toEqual({ approvalMode: 'auto_edit' });
expect(out.grokConfig).toEqual({ alwaysApprove: false });
});
});
+26
View File
@@ -461,6 +461,32 @@ describe('ralph-routes', () => {
// ========== POST /api/ralph-loop/start ==========
describe('POST /api/ralph-loop/start', () => {
it('awaits layout insertion and stops lifecycle work when registration rejects', async () => {
let rejectRegistration!: (error: Error) => void;
harness.ctx.addSession.mockImplementationOnce(
() =>
new Promise<void>((_resolve, reject) => {
rejectRegistration = reject;
})
);
const pending = harness.app.inject({
method: 'POST',
url: '/api/ralph-loop/start',
payload: { taskDescription: 'test task', completionPhrase: 'DONE', caseName: 'registration-order' },
});
await vi.waitFor(() => expect(harness.ctx.addSession).toHaveBeenCalledTimes(1));
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
rejectRegistration(new Error('layout capacity exceeded'));
const response = await pending;
expect(response.statusCode).toBe(500);
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
expect(harness.ctx.broadcast).not.toHaveBeenCalledWith('session:created', expect.anything());
});
it('rejects invalid request body', async () => {
const res = await harness.app.inject({
method: 'POST',
+49 -34
View File
@@ -1,11 +1,11 @@
/**
* @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131).
* @fileoverview Tests for the synchronized legacy PUT /api/session-order endpoint.
*
* Uses app.inject() — no real HTTP ports needed.
* Asserts the uniform envelope contract:
* SUCCESS -> 2xx, { success: true, data: { order } }
* ERROR -> 4xx/5xx, { success: false, error, errorCode }
* and that the order is persisted to the (mock) StateStore + broadcast over SSE.
* Legacy callers are routed through the authenticated owner-scoped tab-layout service.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
@@ -13,6 +13,7 @@ import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { TabLayoutValidationError } from '../../src/tab-layout.js';
// registerSessionRoutes pulls in session.js which can shell out; stub the bits
// that would touch the OS at import/registration time. None are needed by the
@@ -29,11 +30,22 @@ interface LocalHarness {
ctx: MockRouteContext;
}
async function buildHarness(): Promise<LocalHarness> {
async function buildHarness(authUser = { username: 'alice', role: 'user' as const }): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
const ctx = createMockRouteContext();
Object.assign(ctx.tabLayouts, {
putLegacyOrder: vi.fn(async (_actor: unknown, order: string[]) => ({
order: [...order],
changedOwnerOrders: {},
globalOrder: [...order],
globalChanged: true,
})),
});
registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]);
// Mirror production's uniform-envelope preSerialization hook (server.ts).
@@ -60,35 +72,22 @@ describe('PUT /api/session-order', () => {
let harness: LocalHarness;
beforeEach(async () => {
vi.stubEnv('CODEMAN_MULTIUSER', '1');
harness = await buildHarness();
});
afterEach(async () => {
await harness.app.close();
vi.unstubAllEnvs();
});
it('persists the order and returns it in the envelope', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['a', 'b', 'c'] },
it('routes a regular legacy PUT through the authenticated owner layout service', async () => {
vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockResolvedValueOnce({
order: ['a', 'b'],
changedOwnerOrders: { alice: ['a', 'b'] },
globalOrder: ['a', 'b'],
globalChanged: true,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } });
// Persisted to the store.
expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']);
expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']);
// Broadcast over SSE.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] });
});
it('preserves a server-only id (unknown to the pushing device) at the end', async () => {
// Seed the store with an order containing a server-only id "z".
harness.ctx.store.setSessionOrder(['a', 'z', 'b']);
(harness.ctx.broadcast as ReturnType<typeof vi.fn>).mockClear();
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
@@ -96,25 +95,24 @@ describe('PUT /api/session-order', () => {
});
expect(res.statusCode).toBe(200);
const body = res.json();
// Incoming order wins, server-only "z" falls to the end.
expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } });
expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']);
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] });
expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'alice', isAdmin: false }, ['b', 'a']);
expect(res.json().data.order).toEqual(['a', 'b']);
});
it('normalizes junk input (dedup + drop empties) before persisting', async () => {
it('uses the machine-wide admin bridge for an admin caller', async () => {
await harness.app.close();
harness = await buildHarness({ username: 'root', role: 'admin' });
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['a', 'a', '', 'b'] },
payload: { order: ['b', 'a'] },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } });
expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'root', isAdmin: true }, ['b', 'a']);
});
it('rejects a non-array order with a 4xx envelope', async () => {
it('rejects malformed bodies before invoking the service', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
@@ -124,6 +122,23 @@ describe('PUT /api/session-order', () => {
expect(res.statusCode).toBeGreaterThanOrEqual(400);
const body = res.json();
expect(body.success).toBe(false);
expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled();
expect(harness.ctx.tabLayouts.putLegacyOrder).not.toHaveBeenCalled();
});
it('maps owner-boundary validation failures to INVALID_INPUT', async () => {
vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockRejectedValueOnce(
new TabLayoutValidationError('session is not owned by layout owner: foreign')
);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['foreign'] },
});
expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({
success: false,
errorCode: ApiErrorCode.INVALID_INPUT,
});
});
});
+48 -1
View File
@@ -92,6 +92,12 @@ vi.mock('../../src/utils/pi-cli-resolver.js', () => ({
getPiCliVersion: vi.fn(() => null),
}));
vi.mock('../../src/utils/grok-cli-resolver.js', () => ({
isGrokAvailable: vi.fn(() => false),
resolveGrokDir: vi.fn(() => null),
getGrokCliVersion: vi.fn(() => null),
}));
import fs from 'node:fs/promises';
import { existsSync, readdirSync } from 'node:fs';
import { subagentWatcher } from '../../src/subagent-watcher.js';
@@ -100,6 +106,7 @@ import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencod
import { isGeminiAvailable, resolveGeminiDir } from '../../src/utils/gemini-cli-resolver.js';
import { isAntigravityAvailable, resolveAntigravityDir } from '../../src/utils/antigravity-cli-resolver.js';
import { isPiAvailable, resolvePiDir, getPiCliVersion } from '../../src/utils/pi-cli-resolver.js';
import { isGrokAvailable, resolveGrokDir, getGrokCliVersion } from '../../src/utils/grok-cli-resolver.js';
const mockedReadFile = vi.mocked(fs.readFile);
const mockedWriteFile = vi.mocked(fs.writeFile);
@@ -116,6 +123,9 @@ const mockedResolveAntigravityDir = vi.mocked(resolveAntigravityDir);
const mockedIsPiAvailable = vi.mocked(isPiAvailable);
const mockedResolvePiDir = vi.mocked(resolvePiDir);
const mockedGetPiCliVersion = vi.mocked(getPiCliVersion);
const mockedIsGrokAvailable = vi.mocked(isGrokAvailable);
const mockedResolveGrokDir = vi.mocked(resolveGrokDir);
const mockedGetGrokCliVersion = vi.mocked(getGrokCliVersion);
describe('system-routes', () => {
let harness: RouteTestHarness;
@@ -322,11 +332,16 @@ describe('system-routes', () => {
describe('POST /api/cleanup-state', () => {
it('cleans up stale session state', async () => {
const runStaleSessionCleanup = vi.fn(
async (_activeIds: Set<string>, action: (ids: ReadonlySet<string>) => unknown) => action(new Set())
);
harness.ctx.tabLayouts.runStaleSessionCleanup = runStaleSessionCleanup;
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.cleanedSessions).toBe(0);
expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled();
expect(harness.ctx.store.cleanupSessionsByIds).toHaveBeenCalledWith(new Set());
expect(runStaleSessionCleanup).toHaveBeenCalledOnce();
});
});
@@ -881,6 +896,38 @@ describe('system-routes', () => {
});
});
// ========== GET /api/grok/status ==========
describe('GET /api/grok/status', () => {
it('returns unavailable when grok is not installed', async () => {
mockedIsGrokAvailable.mockReturnValue(false);
mockedResolveGrokDir.mockReturnValue(null);
mockedGetGrokCliVersion.mockReturnValue(null);
const res = await harness.app.inject({ method: 'GET', url: '/api/grok/status' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.available).toBe(false);
expect(body.path).toBeNull();
expect(body.version).toBeNull();
});
it('returns available with path AND version when grok is installed', async () => {
// `version` matters for the same reason as pi: `grok` has known squatters,
// so this endpoint is where a misresolution shows up.
mockedIsGrokAvailable.mockReturnValue(true);
mockedResolveGrokDir.mockReturnValue('/home/user/.grok/bin');
mockedGetGrokCliVersion.mockReturnValue('1.0.5');
const res = await harness.app.inject({ method: 'GET', url: '/api/grok/status' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.available).toBe(true);
expect(body.path).toBe('/home/user/.grok/bin');
expect(body.version).toBe('1.0.5');
});
});
// ========== GET /api/execution/model-config ==========
describe('GET /api/execution/model-config', () => {
+106
View File
@@ -0,0 +1,106 @@
/**
* @fileoverview Owner-scoped tab-layout HTTP concurrency and validation contract.
*/
import Fastify from 'fastify';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerTabLayoutRoutes } from '../../src/web/routes/tab-layout-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import type { TabLayout } from '../../src/tab-layout.js';
const layout = (version = 3): TabLayout => ({
version,
groups: [],
ungrouped: [{ kind: 'session', id: 'mine' }],
updatedAt: '2026-08-16T00:00:00.000Z',
});
async function harness(username?: string, role: 'admin' | 'user' = 'user') {
const app = Fastify({ logger: false });
if (username) {
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: { username: string; role: 'admin' | 'user' } }).authUser = { username, role };
});
}
const service = {
get: vi.fn(async () => layout()),
put: vi.fn(async (_owner: string, desired: unknown, baseVersion: number) => ({
status: 'updated' as const,
layout: { ...(desired as TabLayout), version: baseVersion + 1 },
})),
};
registerTabLayoutRoutes(app, { tabLayouts: service } as never);
installRouteErrorHandler(app);
await app.ready();
return { app, service };
}
afterEach(() => vi.unstubAllEnvs());
describe('tab layout routes', () => {
it('maps single-user requests to @single and never accepts an owner override', async () => {
vi.stubEnv('CODEMAN_MULTIUSER', '0');
const { app, service } = await harness();
const response = await app.inject({ method: 'GET', url: '/api/tab-layout?owner=foreign' });
expect(response.statusCode).toBe(200);
expect(service.get).toHaveBeenCalledWith('@single');
await app.close();
});
it('uses the authenticated username in multi-user mode, including for admins', async () => {
vi.stubEnv('CODEMAN_MULTIUSER', '1');
const { app, service } = await harness('admin-a', 'admin');
await app.inject({ method: 'GET', url: '/api/tab-layout?owner=someone-else' });
expect(service.get).toHaveBeenCalledWith('admin-a');
await app.close();
});
it.each([2, 4])('returns 409 with the authoritative prepared layout when baseVersion=%s', async (baseVersion) => {
vi.stubEnv('CODEMAN_MULTIUSER', '1');
const { app, service } = await harness('alice');
service.put.mockResolvedValueOnce({ status: 'conflict', layout: layout(3) });
const response = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion, layout: layout(baseVersion) },
});
expect(response.statusCode).toBe(409);
expect(response.json().success).toBe(false);
expect(response.json().errorCode).toBe('CONFLICT');
expect(response.json().data.layout).toEqual(layout(3));
expect(service.put).toHaveBeenCalledWith('alice', layout(baseVersion), baseVersion);
await app.close();
});
it('rejects malformed writes before invoking the service', async () => {
const { app, service } = await harness();
const response = await app.inject({ method: 'PUT', url: '/api/tab-layout', payload: { baseVersion: -1 } });
expect(response.statusCode).toBe(400);
expect(service.put).not.toHaveBeenCalled();
await app.close();
});
it('rejects extra write keys before invoking the service', async () => {
const { app, service } = await harness();
const response = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion: 3, layout: layout(), owner: 'foreign' },
});
expect(response.statusCode).toBe(400);
expect(response.json().errorCode).toBe('INVALID_INPUT');
expect(service.put).not.toHaveBeenCalled();
await app.close();
});
it('has an explicit conservative body limit', async () => {
const { app } = await harness();
const response = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion: 3, layout: layout(), padding: 'x'.repeat(140 * 1024) },
});
expect(response.statusCode).toBe(413);
await app.close();
});
});
+73 -1
View File
@@ -5,7 +5,7 @@
* the developer's real ~/.codeman/webviews.json.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyWebsocket from '@fastify/websocket';
@@ -16,17 +16,23 @@ import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { webviewCapabilities } from '../../src/webview-capabilities.js';
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
import { TabLayoutService } from '../../src/tab-layout-service.js';
import type { TabLayout } from '../../src/tab-layout.js';
let app: FastifyInstance;
let tmpDir: string;
let savedDataDir: string | undefined;
const broadcasts: Array<{ event: string; data: unknown }> = [];
const webviewCreated = vi.fn(async () => {});
const webviewDeleted = vi.fn(async () => {});
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webviews-'));
savedDataDir = process.env.CODEMAN_DATA_DIR;
process.env.CODEMAN_DATA_DIR = tmpDir;
broadcasts.length = 0;
webviewCreated.mockClear();
webviewDeleted.mockClear();
app = Fastify({ logger: false });
await app.register(fastifyCookie);
@@ -34,6 +40,7 @@ beforeEach(async () => {
await app.register(fastifyWebsocket);
registerWebviewRoutes(app, {
broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }),
tabLayouts: { webviewCreated, webviewDeleted },
} as never);
installRouteErrorHandler(app);
await app.ready();
@@ -90,6 +97,58 @@ describe('POST /api/webviews', () => {
}
});
it('rolls back webview persistence and emits nothing when layout capacity rejects insertion', async () => {
const refs = Array.from({ length: 512 }, (_, index) => ({ kind: 'session' as const, id: `s-${index}` }));
const original: TabLayout = {
version: 9,
groups: [],
ungrouped: refs,
updatedAt: '2026-08-16T00:00:00.000Z',
};
let stored = original;
const live = new Map(refs.map((ref, index) => [ref.id, { id: ref.id, createdAt: index }]));
const atomicBroadcast = vi.fn();
const service = new TabLayoutService({
store: {
getTabLayout: () => stored,
setTabLayout: (_owner, layout) => {
stored = layout;
},
getSessions: () => ({}),
getSessionOrder: () => [],
} as never,
sessions: live,
readWebviews: async () =>
(await import('../../src/webview-store.js')).readWebviews(tmpDir) as Promise<
Array<{ id: string; owner?: string }>
>,
broadcast: atomicBroadcast,
broadcastSessionOrder: vi.fn(),
});
const atomicApp = Fastify({ logger: false });
await atomicApp.register(fastifyCookie);
await atomicApp.register(fastifyWebsocket);
registerWebviewRoutes(atomicApp, {
broadcast: atomicBroadcast,
tabLayouts: service,
} as never);
installRouteErrorHandler(atomicApp);
await atomicApp.ready();
const response = await atomicApp.inject({
method: 'POST',
url: '/api/webviews',
payload: { name: 'overflow', url: 'https://example.test/' },
});
const list = (await atomicApp.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
expect(response.statusCode).toBe(500);
expect(list).toEqual([]);
expect(stored).toEqual(original);
expect(atomicBroadcast).not.toHaveBeenCalled();
await atomicApp.close();
});
it('rejects URLs carrying embedded credentials', async () => {
const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' });
expect(res.statusCode).toBe(400);
@@ -142,6 +201,19 @@ describe('DELETE /api/webviews/:id', () => {
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
});
it('keeps the exact saved record and emits nothing when layout deletion fails', async () => {
const created = (await create({ name: 'Keep me', url: 'https://keep.example/' })).json().data;
broadcasts.length = 0;
webviewDeleted.mockRejectedValueOnce(new Error('tab layout restoration failed'));
const response = await app.inject({ method: 'DELETE', url: `/api/webviews/${created.id}` });
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
expect(response.statusCode).toBe(500);
expect(list).toEqual([created]);
expect(broadcasts).toEqual([]);
});
it('404s an unknown id', async () => {
expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404);
});
+106 -1
View File
@@ -176,6 +176,7 @@ describe('Run launch synchronization', () => {
'runGemini',
'runAntigravity',
'runPi',
'runGrok',
])
);
@@ -365,12 +366,13 @@ describe('Codex quick start settings', () => {
'welcomeAntigravityBtn',
'welcomeGeminiBtn',
'welcomePiBtn',
'welcomeGrokBtn',
'welcomeTunnelBtn',
]) {
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
}
const modeBtns: Record<string, { style: { display: string } }> = {};
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'shell']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) {
modeBtns[mode] = { style: { display: 'PRISTINE' } };
}
const menu = {
@@ -402,6 +404,7 @@ describe('Codex quick start settings', () => {
gemini: false,
antigravity: false,
pi: false,
grok: false,
cloudflared: false,
};
@@ -425,6 +428,13 @@ describe('Codex quick start settings', () => {
const withPi = loadUi({ ...ALL_OFF, pi: true });
withPi.app.applyWelcomeCliVisibility();
expect(withPi.welcomeBtns.welcomePiBtn.style.display).toBe('flex');
// Grok is gated on `grok` like the rest; the resolver additionally
// version-probes the binary, so a stray `grok` on PATH reports unavailable.
const withGrok = loadUi({ ...ALL_OFF, grok: true });
withGrok.app.applyWelcomeCliVisibility();
expect(withGrok.welcomeBtns.welcomeGrokBtn.style.display).toBe('flex');
expect(withGrok.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
expect(withPi.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
// Antigravity is a first-class welcome action, gated on `agy` like the rest.
@@ -457,6 +467,7 @@ describe('Codex quick start settings', () => {
);
expect(offered).toContain('antigravity');
expect(offered).toContain('pi');
expect(offered).toContain('grok');
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
@@ -993,3 +1004,97 @@ describe('Pi quick start', () => {
expect(errors[0]).toContain('@earendil-works/pi-coding-agent');
});
});
describe('Grok quick start', () => {
// Same envelope-unwrap regression guard as the blocks above, for runGrok(),
// plus the rule that makes grok the OPPOSITE of pi: the Run button DOES send
// `grokConfig: { alwaysApprove: true }` (grok's bypassPermissions mode), the
// same product decision as runAntigravity's dangerouslySkipPermissions and
// claude's --dangerously-skip-permissions. The multi-user clamp strips it
// server-side for non-granted owners.
it('drives runGrok() through the {success,data} envelope and sends alwaysApprove', async () => {
const elements: Record<string, any> = {
quickStartCase: { value: 'grok-case' },
};
const requests: Array<{ url: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => elements[id] ?? null },
fetch: async (url: string, init?: { body?: string }) => {
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
if (url === '/api/grok/status')
return {
json: async () => ({
success: true,
data: { available: true, path: '/home/user/.grok/bin', version: '1.0.5' },
}),
};
if (url === '/api/quick-start')
return { json: async () => ({ success: true, data: { sessionId: 'sess-gk' } }) };
if (url === '/api/sessions/sess-gk')
return { json: async () => ({ success: true, data: { id: 'sess-gk', name: 'w1-grok-case' } }) };
throw new Error(`unexpected fetch: ${url}`);
},
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
app.loadAppSettingsFromStorage = () => ({});
app.getCaseSettings = () => ({});
app.buildEnvOverrides = () => ({});
app.sessions = new Map();
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
app._renderSessionTabsImmediate = vi.fn();
const selected: string[] = [];
app.selectSession = async (id: string) => {
selected.push(id);
};
await app.runGrok();
const body = requests.find((req) => req.url === '/api/quick-start')?.body;
expect(body).toMatchObject({
caseName: 'grok-case',
mode: 'grok',
grokConfig: { alwaysApprove: true },
});
expect(selected).toEqual(['sess-gk']);
});
it('reports the install hint when the CLI is missing and starts nothing', async () => {
const elements: Record<string, any> = { quickStartCase: { value: 'grok-case' } };
const requests: string[] = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => elements[id] ?? null },
fetch: async (url: string) => {
requests.push(url);
if (url === '/api/grok/status')
return { json: async () => ({ success: true, data: { available: false, path: null, version: null } }) };
throw new Error(`unexpected fetch: ${url}`);
},
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
const errors: string[] = [];
app._reportSessionLaunchError = (_owns: boolean, msg: string) => errors.push(msg);
await app.runGrok();
expect(requests).toEqual(['/api/grok/status']);
expect(errors[0]).toContain('https://x.ai/cli/install.sh');
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { WebServer } from '../src/web/server.js';
describe('scheduled session layout registration', () => {
it('registers the layout before lifecycle work and rolls back a rejected tentative session', async () => {
const sessions = new Map<string, { id: string; owner?: string }>();
const session = { id: 'scheduled-session', owner: 'alice' };
const sessionCreated = vi.fn(async () => {
throw new Error('layout unavailable');
});
const server = Object.create(WebServer.prototype) as {
sessions: typeof sessions;
tabLayouts: { sessionCreated: typeof sessionCreated };
registerSessionWithLayout(session: typeof session): Promise<void>;
};
server.sessions = sessions;
server.tabLayouts = { sessionCreated };
await expect(server.registerSessionWithLayout(session)).rejects.toThrow('layout unavailable');
expect(sessionCreated).toHaveBeenCalledWith('alice');
expect(sessions.has(session.id)).toBe(false);
});
it('uses the shared registration helper in the scheduled loop before persistence and listeners', () => {
const source = readFileSync(new URL('../src/web/server.ts', import.meta.url), 'utf8');
const loop = source.slice(
source.indexOf('private async runScheduledLoop'),
source.indexOf('private async stopScheduledRun')
);
expect(loop).toContain('await this.registerSessionWithLayout(session);');
expect(loop.indexOf('await this.registerSessionWithLayout(session);')).toBeLessThan(
loop.indexOf('this.store.incrementSessionsCreated();')
);
expect(loop.indexOf('await this.registerSessionWithLayout(session);')).toBeLessThan(
loop.indexOf('await this.setupSessionListeners(session);')
);
});
});
+31 -2
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { existsSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
const TEST_PORT = 3120;
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -15,10 +15,12 @@ const CASES_DIR = join(homedir(), 'codeman-cases');
describe('Session Cleanup', () => {
let server: WebServer;
let baseUrl: string;
let testWorkingDir: string;
const createdCases: string[] = [];
const createdSessions: string[] = [];
beforeAll(async () => {
testWorkingDir = mkdtempSync(join(tmpdir(), 'codeman-cleanup-test-'));
server = new WebServer(TEST_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT}`;
@@ -43,9 +45,36 @@ describe('Session Cleanup', () => {
} catch {}
}
await server.stop();
rmSync(testWorkingDir, { recursive: true, force: true });
}, 60000);
describe('Session Deletion', () => {
it('rejects before stopping the session when layout deletion preparation fails', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: testWorkingDir }),
});
const created = await createRes.json();
const id = created.data.session.id;
createdSessions.push(id);
const internals = server as unknown as {
tabLayouts: { runSessionDeletion: (...args: unknown[]) => Promise<unknown> };
};
const deletionSpy = vi
.spyOn(internals.tabLayouts, 'runSessionDeletion')
.mockRejectedValueOnce(new Error('layout prune unavailable'));
try {
const deleteRes = await fetch(`${baseUrl}/api/sessions/${id}`, { method: 'DELETE' });
const getRes = await fetch(`${baseUrl}/api/sessions/${id}`);
expect(deleteRes.status).toBe(500);
expect(getRes.status).toBe(200);
} finally {
deletionSpy.mockRestore();
}
});
it('should properly stop and cleanup interactive session', async () => {
const caseName = `cleanup-test-${Date.now()}`;
createdCases.push(caseName);
+86 -2
View File
@@ -12,6 +12,10 @@ import { describe, expect, it } from 'vitest';
type Rect = { left: number; top: number; width: number; height: number };
type LineagePath = { d: string; endX: number; endY: number; sameRow: boolean } | null;
type Orientation = 'horizontal' | 'vertical';
const lineageJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-lineage.js'), 'utf8');
const stylesCss = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
function loadLineageHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
@@ -20,7 +24,13 @@ function loadLineageHelper() {
return (
context.window as {
CodemanLineage: {
computePath: (input: { parent: Rect | null; child: Rect | null; strip?: Rect; depth?: number }) => LineagePath;
computePath: (input: {
parent: Rect | null;
child: Rect | null;
strip?: Rect;
depth?: number;
orientation?: Orientation;
}) => LineagePath;
DIP_MIN_PX: number;
DIP_MAX_PX: number;
SIBLING_STEP_PX: number;
@@ -48,7 +58,11 @@ function loadLineageApp(): { app: LineageApp; sandbox: LineageSandbox } {
globalThis: {},
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop' },
document: { getElementById: () => null, createElementNS: () => null },
document: {
documentElement: { getAttribute: () => 'horizontal' },
getElementById: () => null,
createElementNS: () => null,
},
};
const context = vm.createContext(sandbox);
for (const file of ['constants.js', 'session-lineage.js']) {
@@ -258,6 +272,76 @@ describe('lineage line geometry', () => {
).toBeNull();
});
it('routes vertical tabs through the empty left gutter instead of their shared centerline', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(geom).not.toBeNull();
expect(nums).toHaveLength(5);
expect(nums[0]).toBe(parent.left);
expect(nums[1]).toBe(parent.top + parent.height / 2);
expect(nums[2]).toBeGreaterThan(strip.left);
expect(nums[2]).toBeLessThan(parent.left);
expect(nums[3]).toBe(child.top + child.height / 2);
expect(nums[4]).toBe(child.left);
expect(geom.endX).toBe(child.left);
expect(geom.endY).toBe(child.top + child.height / 2);
});
it('offsets vertical sibling tracks without moving either tab endpoint', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
const first = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 0 })!;
const second = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 1 })!;
const numbers = (d: string) => d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(numbers(second.d)[2]).toBeGreaterThan(numbers(first.d)[2]);
expect([second.endX, second.endY]).toEqual([first.endX, first.endY]);
});
it('keeps the same gutter shape when the child sits above its parent', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 220, width: 260, height: 40 };
const child: Rect = { left: 132, top: 60, width: 260, height: 40 };
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(nums).toEqual([parent.left, 240, expect.any(Number), 80, child.left]);
expect(nums[2]).toBeGreaterThan(strip.left);
expect(nums[2]).toBeLessThan(parent.left);
expect([geom.endX, geom.endY]).toEqual([child.left, 80]);
});
it('clips vertical lineage by the visible Y range after rail scrolling', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 100, width: 320, height: 300 };
const visible: Rect = { left: 132, top: 160, width: 260, height: 40 };
const above: Rect = { left: 132, top: 20, width: 260, height: 40 };
const below: Rect = { left: 132, top: 460, width: 260, height: 40 };
expect(helper.computePath({ parent: above, child: visible, strip, orientation: 'vertical' })).toBeNull();
expect(helper.computePath({ parent: visible, child: below, strip, orientation: 'vertical' })).toBeNull();
expect(
helper.computePath({ parent: visible, child: { ...visible, top: 300 }, strip, orientation: 'vertical' })
).not.toBeNull();
});
it('passes the resolved DOM orientation into geometry and reserves a vertical gutter', () => {
expect(lineageJs).toContain("getAttribute('data-tab-orientation')");
expect(lineageJs).toMatch(/compute\(\{[\s\S]{0,180}orientation/);
const selector = "html[data-tab-orientation='vertical'] .tab-rail .session-tabs {";
const verticalRailBlock = stylesCss.slice(stylesCss.indexOf(selector), stylesCss.indexOf(selector) + 600);
expect(verticalRailBlock).toContain('--lineage-vertical-gutter');
expect(verticalRailBlock).toContain('padding-left');
});
it('still draws when no strip rect is supplied (clipping is opt-in)', () => {
const helper = loadLineageHelper();
const geom = helper.computePath({ parent: tab(0), child: tab(9000) });
+51
View File
@@ -34,6 +34,7 @@ const SCHEMAS = readFileSync(new URL('../src/web/schemas.ts', import.meta.url),
interface LayoutApp {
soloSessionId: string | null;
isSoloWindow: boolean;
sessions: Map<string, unknown>;
sessionOrder: string[];
_tallTabsEnabled?: boolean;
@@ -44,6 +45,7 @@ interface LayoutApp {
isSessionSidebarActive(): boolean;
isSessionSidebarCollapsed(): boolean;
applySessionListLayout(): void;
applyTabOrientation(): void;
toggleSessionSidebar(): void;
updateSidebarCount(): void;
closeSessionSidebarOnHandheld(): void;
@@ -86,6 +88,7 @@ const SHELL = `
</div>
</header>
<main class="main">
<div class="tab-rail" id="tabRail"></div>
<aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions">
<div class="session-sidebar-head">
<span class="session-sidebar-title">Sessions</span>
@@ -154,6 +157,7 @@ function boot(
// terminal stack. Only the layout surface is under test here.
const app = Object.create(win.__CodemanApp.prototype) as LayoutApp;
app.soloSessionId = options.solo ?? null;
app.isSoloWindow = !!app.soloSessionId;
app.sessions = new Map();
app.sessionOrder = [];
app._elemCache = new Map();
@@ -177,6 +181,39 @@ describe('session list layout', () => {
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true);
});
it('preserves vertical rail ownership when the session-list layout reapplies', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
app.applySessionListLayout();
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
app.applySessionListLayout();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
});
it('keeps aria orientation synchronized when tab orientation moves hosts', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
app.applySessionListLayout();
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
win.localStorage.setItem(
'codeman-app-settings',
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'horizontal' })
);
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal');
});
it('re-parents the tab list into the sidebar and flips the a11y state', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
expect(app.getSessionListLayout()).toBe('sidebar');
@@ -225,6 +262,20 @@ describe('session list layout', () => {
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
});
it('forces horizontal tabs in a solo window even when vertical orientation is preferred', () => {
const { win, app } = boot({
stored: { sessionListLayout: 'header', tabOrientation: 'vertical' },
solo: 'sess-1',
});
app.applySessionListLayout();
app.applyTabOrientation();
expect(win.document.documentElement.dataset.tabOrientation).toBe('horizontal');
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal');
});
it('round-trips the collapse state through its own storage key', () => {
// Deliberately NOT in the app-settings blob: saveAppSettings() rebuilds that
// blob from the DOM controls, so a key without a control is wiped on Save.
@@ -0,0 +1,77 @@
/** Real-browser responsive layout coverage for Session Options. */
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 html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const mobileStyles = readFileSync(resolve(publicDir, 'mobile.css'), 'utf8');
function sessionOptionsMarkup() {
const start = html.indexOf('<div class="modal" id="sessionOptionsModal">');
const end = html.indexOf('<!-- Close Session Confirmation Modal -->', start);
if (start < 0 || end < 0) throw new Error('Session Options markup not found');
return html.slice(start, end);
}
describe('Session Options responsive layout in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
});
afterAll(async () => browser.close());
async function renderAt(width: number) {
await page.setViewportSize({ width, height: 1000 });
await page.setContent(`<!doctype html><html><head><style>${styles}</style>
<style>@media (max-width: 1023px) { ${mobileStyles} }</style></head>
<body>${sessionOptionsMarkup()}</body></html>`);
await page.evaluate(() => {
document.getElementById('sessionOptionsModal')!.classList.add('active');
document
.querySelectorAll('#sessionOptionsModal .set-section')
.forEach((section) => section.classList.add('hidden'));
document.getElementById('context-tab')!.classList.remove('hidden');
});
}
async function metrics() {
return page.evaluate(() => {
const modal = document.querySelector<HTMLElement>('#sessionOptionsModal .modal-content')!;
const doc = document.getElementById('sessionOptionsDoc')!;
const panel = document.getElementById('context-tab')!;
const header = panel.querySelector<HTMLElement>(':scope > .set-section-head')!;
const blurb = panel.querySelector<HTMLElement>(':scope > .set-section-blurb')!;
return {
modalWidth: modal.getBoundingClientRect().width,
docFits: doc.scrollWidth === doc.clientWidth,
panelFits: panel.scrollWidth === panel.clientWidth,
tracks: getComputedStyle(panel).gridTemplateColumns.split(' '),
headerGridColumn: getComputedStyle(header).gridColumn,
blurbGridColumn: getComputedStyle(blurb).gridColumn,
};
});
}
it('uses one fitting column at the tablet-width desktop viewport', async () => {
await renderAt(974);
expect(await metrics()).toMatchObject({ docFits: true, panelFits: true, tracks: [expect.any(String)] });
});
it('uses two fitting columns with a full-width introduction on wide screens', async () => {
await renderAt(1440);
const layout = await metrics();
expect(layout.modalWidth).toBeGreaterThan(1000);
expect(layout.tracks).toHaveLength(2);
expect(layout.headerGridColumn).toBe('1 / -1');
expect(layout.blurbGridColumn).toBe('1 / -1');
expect(layout.docFits).toBe(true);
expect(layout.panelFits).toBe(true);
});
});
+9
View File
@@ -16,6 +16,7 @@ import { resolve } from 'node:path';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
/** The Session Options markup, so assertions can't be satisfied elsewhere. */
function optionsModal(): string {
@@ -94,4 +95,12 @@ describe('Session Options modal structure', () => {
expect(css).toContain(':is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {');
expect(css).toContain(':is(#sessionOptionsModal, #createCaseModal) .set-section.hidden {');
});
it('uses document-safe context columns and widens only at the desktop breakpoint', () => {
expect(styles).toMatch(/#sessionOptionsModal #context-tab\s*\{[^}]*minmax\(0, 1fr\)/s);
expect(styles).toMatch(
/@media \(min-width: 1200px\)[\s\S]*#sessionOptionsModal #context-tab[^}]*repeat\(2, minmax\(0, 1fr\)\)/
);
expect(styles).not.toMatch(/@media \(min-width: 680px\)[\s\S]{0,1200}#sessionOptionsModal #context-tab/);
});
});
+244
View File
@@ -0,0 +1,244 @@
/** @fileoverview Trusted recipient selection for legacy session-order SSE compatibility. */
import type { FastifyReply } from 'fastify';
import { describe, expect, it } from 'vitest';
import type { SessionOrderProjectionChange } from '../src/tab-layout-service.js';
import { CleanupManager } from '../src/utils/index.js';
import { sessionOrderPayloadFor } from '../src/web/session-order-sse.js';
import { SseStreamManager } from '../src/web/sse-stream-manager.js';
function changeWith(
changedOwnerOrders: Record<string, string[]>,
globalOrder: string[],
globalChanged: boolean
): SessionOrderProjectionChange {
return { changedOwnerOrders, globalOrder, globalChanged };
}
function client() {
const writes: string[] = [];
return {
writes,
reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply,
};
}
function backpressuredClient(options: { throwAfterBackpressure?: boolean } = {}) {
const writes: string[] = [];
let firstWrite = true;
let onDrain: (() => void) | undefined;
const raw = {
write(chunk: string) {
if (!firstWrite && options.throwAfterBackpressure) throw new Error('client disconnected');
writes.push(chunk);
if (firstWrite) {
firstWrite = false;
return false;
}
return true;
},
once(event: string, callback: () => void) {
if (event === 'drain') onDrain = callback;
return raw;
},
};
return {
writes,
reply: { raw } as unknown as FastifyReply,
drain: () => {
const callback = onDrain;
onDrain = undefined;
callback?.();
},
};
}
describe('legacy session-order SSE routing', () => {
it('selects an owner slice for the matching regular user and nothing for another user', () => {
const change = changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true);
expect(sessionOrderPayloadFor({ username: 'alice', role: 'user' }, change)).toEqual({ order: ['a2', 'a1'] });
expect(sessionOrderPayloadFor({ username: 'bob', role: 'user' }, change)).toBeUndefined();
});
it('does not treat inherited object properties as changed owner slices', () => {
const change = changeWith({ alice: ['a1'] }, ['a1'], true);
expect(sessionOrderPayloadFor({ username: 'constructor', role: 'user' }, change)).toBeUndefined();
});
it('selects the global projection for admins even when only interleaving changed', () => {
const change = changeWith({}, ['b1', 'a1'], true);
expect(sessionOrderPayloadFor({ username: 'admin', role: 'admin' }, change)).toEqual({ order: ['b1', 'a1'] });
});
it('uses the global projection for identity-less single-user clients', () => {
const change = changeWith({ '@single': ['s2', 's1'] }, ['s2', 's1'], true);
expect(sessionOrderPayloadFor(undefined, change)).toEqual({ order: ['s2', 's1'] });
});
it('delivers owner slices to every matching device, the global order to admins, and nothing to other users', () => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const alicePhone = client();
const aliceDesktop = client();
const bob = client();
const admin = client();
manager.addClient(alicePhone.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(aliceDesktop.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true));
const ownerFrame = 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n';
expect(alicePhone.writes).toEqual([ownerFrame]);
expect(aliceDesktop.writes).toEqual([ownerFrame]);
expect(bob.writes).toEqual([]);
expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a2","b1","a1"]}\n\n']);
cleanup.dispose();
});
it('delivers a global-only interleaving change to admins only', () => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const alice = client();
const admin = client();
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
manager.broadcastSessionOrder(changeWith({}, ['b1', 'a1'], true));
expect(alice.writes).toEqual([]);
expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["b1","a1"]}\n\n']);
cleanup.dispose();
});
it.each([
{
label: 'order-only repair',
change: changeWith({ alice: ['a', 'b'] }, ['a', 'bob-1', 'b'], true),
ownerOrder: ['a', 'b'],
globalOrder: ['a', 'bob-1', 'b'],
},
{
label: 'legacy-only deletion',
change: changeWith({ alice: [] }, ['bob-1'], true),
ownerOrder: [],
globalOrder: ['bob-1'],
},
])('delivers an $label correction to same-owner devices and admins only', ({ change, ownerOrder, globalOrder }) => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const alicePhone = client();
const aliceDesktop = client();
const bob = client();
const admin = client();
manager.addClient(alicePhone.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(aliceDesktop.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
manager.broadcastSessionOrder(change);
const ownerFrame = `event: session:orderChanged\ndata: ${JSON.stringify({ order: ownerOrder })}\n\n`;
expect(alicePhone.writes).toEqual([ownerFrame]);
expect(aliceDesktop.writes).toEqual([ownerFrame]);
expect(bob.writes).toEqual([]);
expect(admin.writes).toEqual([`event: session:orderChanged\ndata: ${JSON.stringify({ order: globalOrder })}\n\n`]);
cleanup.dispose();
});
it('skips an inherited-key username without starving later matching and admin recipients', () => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const constructorUser = client();
const alice = client();
const admin = client();
manager.addClient(constructorUser.reply, null, false, undefined, { username: 'constructor', role: 'user' });
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
expect(() => manager.broadcastSessionOrder(changeWith({ alice: ['a1'] }, ['a1'], true))).not.toThrow();
expect(constructorUser.writes).toEqual([]);
expect(alice.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a1"]}\n\n']);
expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a1"]}\n\n']);
cleanup.dispose();
});
it('coalesces the latest filtered owner order while backpressured and flushes it on drain', () => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const blockedAlice = backpressuredClient();
const liveAlice = client();
const bob = client();
const admin = client();
manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
for (const writes of [blockedAlice.writes, liveAlice.writes, bob.writes, admin.writes]) writes.length = 0;
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true));
manager.broadcastSessionOrder(changeWith({ alice: ['a1', 'a2'] }, ['b1', 'a1', 'a2'], true));
expect(blockedAlice.writes).toEqual([]);
expect(liveAlice.writes).toEqual([
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
'event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n',
]);
expect(bob.writes).toEqual([]);
expect(admin.writes).toEqual([
'event: session:orderChanged\ndata: {"order":["a2","b1","a1"]}\n\n',
'event: session:orderChanged\ndata: {"order":["b1","a1","a2"]}\n\n',
]);
blockedAlice.drain();
expect(blockedAlice.writes).toEqual([
'event: session:needsRefresh\ndata: {}\n\n',
'event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n',
]);
cleanup.dispose();
});
it('clears a queued owner order when a backpressured client disconnects', () => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const alice = backpressuredClient();
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
alice.writes.length = 0;
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'a1'], true));
manager.removeClient(alice.reply);
alice.drain();
expect(alice.writes).toEqual([]);
cleanup.dispose();
});
it('isolates a drain write failure from later healthy recipients', () => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const brokenAlice = backpressuredClient({ throwAfterBackpressure: true });
const liveAlice = client();
const admin = client();
manager.addClient(brokenAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
brokenAlice.writes.length = 0;
liveAlice.writes.length = 0;
admin.writes.length = 0;
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'a1'], true));
expect(() => brokenAlice.drain()).not.toThrow();
expect(manager.clientCount).toBe(2);
manager.broadcastSessionOrder(changeWith({ alice: ['a1', 'a2'] }, ['a1', 'a2'], true));
expect(liveAlice.writes.at(-1)).toBe('event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n');
expect(admin.writes.at(-1)).toBe('event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n');
cleanup.dispose();
});
});
+106
View File
@@ -0,0 +1,106 @@
/** Real Chromium visibility and click coverage for nested vertical session actions. */
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 styles = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
const controller = readFileSync(resolve(import.meta.dirname, '../src/web/public/tab-rail-resize.js'), 'utf8');
function fixture(surface: 'sidebar' | 'rail', active = false) {
const root =
surface === 'sidebar'
? 'data-session-list="sidebar" data-sidebar="expanded" data-tab-orientation="horizontal"'
: 'data-session-list="header" data-sidebar="expanded" data-tab-orientation="vertical"';
const hostClass = surface === 'sidebar' ? 'session-sidebar' : 'tab-rail';
return `<!doctype html><html ${root}><head><style>${styles}</style></head><body>
<div class="${hostClass}"><div class="session-tabs">
<div class="session-tab${active ? ' active' : ''}" tabindex="0">
<span class="tab-info"><span class="tab-name-row"><span class="tab-name">session</span>
<span class="tab-actions"><button class="tab-more" type="button">…</button></span>
</span></span>
</div>
</div></div>
</body></html>`;
}
async function installActionMenuController(page: Page) {
await page.addScriptTag({ content: 'class CodemanApp {}; window.CodemanApp = CodemanApp;' });
await page.addScriptTag({ content: controller });
await page.evaluate(() => {
const app = new (window as any).CodemanApp();
app.loadAppSettingsFromStorage = () => ({ showTabDetachButton: false });
app.openSessionOptions = () => undefined;
app.requestCloseSession = () => undefined;
document
.querySelector('.tab-more')
?.addEventListener('click', (event) => app.openTabRailActionMenu(event, 'session-1'));
(window as any).app = app;
});
}
async function actionState(page: Page) {
return page.locator('.tab-more').evaluate((node) => {
const style = getComputedStyle(node);
return { visibility: style.visibility, pointerEvents: style.pointerEvents };
});
}
describe('expanded vertical session actions 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 } });
});
afterAll(async () => {
await browser.close();
});
for (const surface of ['sidebar', 'rail'] as const) {
it(`${surface} hides inactive actions, reveals them contextually, and opens the menu`, async () => {
await page.setContent(fixture(surface));
await installActionMenuController(page);
await page.mouse.move(1200, 760);
const tab = page.locator('.session-tab');
expect(await actionState(page)).toEqual({ visibility: 'hidden', pointerEvents: 'none' });
await tab.hover();
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
await page.locator('.tab-more').click();
expect(await page.locator('.tab-rail-action-menu').count()).toBe(1);
await page.setContent(fixture(surface));
await installActionMenuController(page);
await page.locator('.session-tab').focus();
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
await page.setContent(fixture(surface, true));
await installActionMenuController(page);
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
});
}
it('keeps nested actions reachable for a coarse pointer', async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, hasTouch: true });
try {
const touchPage = await context.newPage();
for (const surface of ['sidebar', 'rail'] as const) {
await touchPage.setContent(fixture(surface));
expect(await actionState(touchPage)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
}
} finally {
await context.close();
}
});
it('closes the real controller menu when viewport geometry changes', async () => {
await page.setContent(fixture('rail', true));
await installActionMenuController(page);
await page.locator('.tab-more').click();
expect(await page.locator('.tab-rail-action-menu').count()).toBe(1);
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
expect(await page.locator('.tab-rail-action-menu').count()).toBe(0);
});
});
+75
View File
@@ -0,0 +1,75 @@
/** Structural and schema coverage for vertical session navigation density and actions. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const app = readFileSync(resolve(publicDir, 'app.js'), 'utf8');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const i18n = readFileSync(resolve(publicDir, 'i18n.js'), 'utf8');
const railController = readFileSync(resolve(publicDir, 'tab-rail-resize.js'), 'utf8');
describe('vertical session navigation UX contract', () => {
it('accepts only integer session-name sizes from 11 through 18 pixels', () => {
for (const value of [11, 14, 18]) {
expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(true);
}
for (const value of [10, 19, 14.5, '14']) {
expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(false);
}
});
it('renders one existing action cluster through a shared placement resolver', () => {
expect(app).toContain('shouldInlineSessionActions()');
expect(app).toContain('const inlineSessionActions = this.shouldInlineSessionActions();');
expect(app).toContain('const tabActionsHtml =');
expect(app).toContain("${inlineSessionActions ? tabActionsHtml : ''}");
expect(app).toContain("${inlineSessionActions ? '' : tabActionsHtml}");
expect(app.match(/class="tab-actions"/g)).toHaveLength(1);
expect(app).toContain("tab.querySelector(':scope > .tab-actions')");
});
it('limits inline actions to expanded sidebar and expanded non-compact rail', () => {
expect(app).toMatch(/isSessionSidebarActive\(\)[\s\S]{0,100}!this\.isSessionSidebarCollapsed\(\)/);
expect(app).toMatch(/_tabOrientation\(\) === 'vertical'[\s\S]{0,120}tab-rail-compact/);
expect(styles).toContain("html[data-session-list='sidebar'][data-sidebar='expanded']");
expect(styles).toContain("html[data-tab-orientation='vertical']:not(.tab-rail-compact)");
});
it('opens only the existing session actions from the overflow trigger', () => {
expect(railController).toContain('openTabRailActionMenu(event, sessionId)');
expect(railController).toContain("label: 'Session options'");
expect(railController).toContain("label: 'Open in a new window'");
expect(railController).toContain("label: 'Close session'");
expect(railController).not.toContain('Move to group');
});
it('wires the name-only size through first paint, settings, defaults, and both vertical surfaces', () => {
expect(html).toMatch(/id="appSettingsSessionSidebarFontSize"[^>]*min="11"[^>]*max="18"[^>]*step="1"/);
expect(html).toContain('aria-labelledby="appSettingsSessionSidebarFontSizeLabel"');
expect(html).toContain('--session-sidebar-name-font-size');
expect(settingsUi).toContain('sessionSidebarFontSize: this.resolveSessionSidebarFontSize(');
// 12 = the sidebar's historical 0.75rem name size: the default must
// never restyle an install whose user never touched the slider.
expect(settingsUi).toContain('sessionSidebarFontSize: 12,');
expect(settingsUi).toContain("'sessionSidebarFontSize'");
expect(app).toContain('resolveSessionSidebarFontSize(value)');
expect(app).toContain('applySessionSidebarFontSize(settings = null)');
expect(styles).toMatch(
/\.session-sidebar \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 12px\)/s
);
expect(styles).toMatch(
/\.tab-rail \.session-tab \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 12px\)/s
);
});
it('labels and translates the name-only scope', () => {
expect(html).toContain('Session Name Font Size');
expect(html).toContain('Adjust only session names in the vertical sidebar.');
expect(i18n).toContain("'Session Name Font Size':");
expect(i18n).toContain("'Adjust only session names in the vertical sidebar.':");
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* @fileoverview Atomic StateStore publication tests for tab layouts and their legacy session-order projection.
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { StateStore } from '../src/state-store.js';
import { recomposeGlobalSessionOrder } from '../src/tab-layout-legacy-order.js';
import type { TabLayout } from '../src/tab-layout.js';
const tempDirs: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function createStore(seed: { sessionOrder?: string[]; tabLayouts?: Record<string, TabLayout> } = {}): StateStore {
const dir = mkdtempSync(join(tmpdir(), 'codeman-state-tab-layout-'));
tempDirs.push(dir);
const file = join(dir, 'state.json');
writeFileSync(file, JSON.stringify(seed));
return new StateStore(file);
}
const ownerLayout = (ids: readonly string[], version = 1): TabLayout => ({
version,
groups: [],
ungrouped: ids.map((id) => ({ kind: 'session', id })),
updatedAt: `2026-08-23T12:00:0${version}.000Z`,
});
describe('StateStore.commitTabLayoutProjection', () => {
it('returns a defensive snapshot of all stored owner layouts for trusted owner discovery', () => {
const alice = ownerLayout(['a1'], 1);
const store = createStore({ tabLayouts: { alice, constructor: ownerLayout(['c1'], 2) } });
const layouts = store.getTabLayouts();
expect(Object.keys(layouts)).toEqual(['alice', 'constructor']);
layouts.alice.ungrouped[0].id = 'caller-mutated';
expect(store.getTabLayout('alice')).toEqual(alice);
});
it('validates all layouts and publishes them with a latest-state projection using one save schedule', () => {
const originalAlice = ownerLayout(['a1'], 1);
const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: originalAlice } });
const save = vi.spyOn(store, 'save').mockImplementation(() => undefined);
const alice = ownerLayout(['a2', 'a1'], 2);
const constructorOwner = ownerLayout(['constructor'], 1);
const projected = ['a2', 'a1', 'constructor', 'a2'];
const result = store.commitTabLayoutProjection({ alice, constructor: constructorOwner }, (latest) => {
expect(latest).toEqual(['a1']);
expect(store.getSessionOrder()).toEqual(['a1']);
expect(store.getTabLayout('alice')).toEqual(originalAlice);
(latest as string[]).push('projector-local-mutation');
return projected;
});
expect(store.getTabLayout('alice')).toEqual(alice);
expect(store.getTabLayout('constructor')).toEqual(constructorOwner);
expect(store.getSessionOrder()).toEqual(['a2', 'a1', 'constructor']);
expect(result).toEqual({
layouts: { alice, constructor: constructorOwner },
sessionOrder: ['a2', 'a1', 'constructor'],
});
expect(save).toHaveBeenCalledTimes(1);
alice.ungrouped[0].id = 'caller-mutated';
constructorOwner.ungrouped[0].id = 'caller-mutated';
projected[0] = 'caller-mutated';
result.layouts.alice.ungrouped[0].id = 'return-mutated';
result.layouts.constructor.ungrouped[0].id = 'return-mutated';
result.sessionOrder[0] = 'return-mutated';
expect(store.getTabLayout('alice')?.ungrouped[0].id).toBe('a2');
expect(store.getTabLayout('constructor')?.ungrouped[0].id).toBe('constructor');
expect(store.getSessionOrder()).toEqual(['a2', 'a1', 'constructor']);
});
it('changes neither representation and does not project or save when any layout is invalid', () => {
const original = ownerLayout(['a1'], 1);
const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: original } });
const save = vi.spyOn(store, 'save').mockImplementation(() => undefined);
const project = vi.fn(() => ['a2']);
const invalid = { ...ownerLayout(['b1'], 2), version: -1 };
expect(() => store.commitTabLayoutProjection({ bob: ownerLayout(['b1'], 2), alice: invalid }, project)).toThrow(
/version/
);
expect(project).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
expect(store.getTabLayout('alice')).toEqual(original);
expect(store.getTabLayout('bob')).toBeNull();
expect(store.getSessionOrder()).toEqual(['a1']);
});
it('changes neither representation and does not save when projection throws', () => {
const original = ownerLayout(['a1'], 1);
const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: original } });
const save = vi.spyOn(store, 'save').mockImplementation(() => undefined);
expect(() =>
store.commitTabLayoutProjection({ alice: ownerLayout(['a2'], 2) }, () => {
throw new Error('projection rejected');
})
).toThrow('projection rejected');
expect(save).not.toHaveBeenCalled();
expect(store.getTabLayout('alice')).toEqual(original);
expect(store.getSessionOrder()).toEqual(['a1']);
});
it('derives sequential owner projections from the latest committed order', () => {
const store = createStore({ sessionOrder: ['a1', 'b1', 'a2', 'b2'] });
const save = vi.spyOn(store, 'save').mockImplementation(() => undefined);
store.commitTabLayoutProjection({ alice: ownerLayout(['a2', 'a1'], 2) }, (latest) =>
recomposeGlobalSessionOrder(latest, [{ owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] }])
);
store.commitTabLayoutProjection({ bob: ownerLayout(['b2', 'b1'], 2) }, (latest) =>
recomposeGlobalSessionOrder(latest, [{ owner: 'bob', ownedIds: ['b1', 'b2'], order: ['b2', 'b1'] }])
);
expect(store.getSessionOrder()).toEqual(['a2', 'b2', 'a1', 'b1']);
expect(store.getTabLayout('alice')).toEqual(ownerLayout(['a2', 'a1'], 2));
expect(store.getTabLayout('bob')).toEqual(ownerLayout(['b2', 'b1'], 2));
expect(save).toHaveBeenCalledTimes(2);
});
});
+14
View File
@@ -199,6 +199,20 @@ describe('StateStore', () => {
expect(store.getSession('pinned-1')).not.toBeNull();
expect(store.getSession('plain-1')).toBeNull();
});
it('cleans only requested unpinned session ids', () => {
const store = new StateStore(testFilePath);
store.setSession('requested', createMockSessionState('requested'));
store.setSession('pinned', { ...createMockSessionState('pinned'), pinned: true, pinnedAt: Date.now() });
store.setSession('unrequested', createMockSessionState('unrequested'));
const result = store.cleanupSessionsByIds(new Set(['requested', 'pinned', 'missing']));
expect(result.cleaned.map((session) => session.id)).toEqual(['requested']);
expect(store.getSession('requested')).toBeNull();
expect(store.getSession('pinned')).not.toBeNull();
expect(store.getSession('unrequested')).not.toBeNull();
});
});
describe('task operations', () => {

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