Compare commits

..

158 Commits

Author SHA1 Message Date
nntrivi2001 e30fefab49 feat(update): make /ccam-update pull the latest ccam plugin version, not just rebuild the current one
Docs and the command's own instructions previously implied /ccam-update
fetches new code; it only reinstalled deps and restarted the server
against whatever version was already resolved. It now also runs
`claude plugin marketplace update` + `claude plugin update ccam@<mp> -y`
first, and says plainly that /reload-plugins is still a manual step
(no CLI/script equivalent exists to apply it automatically).
2026-08-18 16:43:37 +07:00
nntrivi2001 8aaa200dd9 fix(run): re-attach a lane's live terminal after the Workspace page remounts
The lane-switch effect only synced `handle` from whatever activeRuns
snapshot was already loaded, so navigating away and back to /run (a
fresh mount with activeRuns still null) left a running lane's console
stuck on the setup form until the user switched lanes and back.
2026-08-18 16:28:36 +07:00
nntrivi2001 8e49d2b300 feat(updates): auto-apply and self-restart on new dashboard versions
Adds POST /api/updates/apply (pull, rebuild, restart) plus an "Update now"
button and hourly auto-check in the UI, for checkouts that are safely
fast-forwardable. Reintroduces self-restart (previously removed in edc25ca
for cross-environment reliability concerns) per explicit user request.
2026-08-18 16:04:27 +07:00
nntrivi2001 f053051a5d fix(run): scope resume session picker to the typed cwd when no lane is selected
Without a lane, the picker had no cwd filter and listed every session
across every repo. Fall back to the free-typed cwd field so resume
suggestions stay scoped to the working directory in view.
2026-08-18 14:51:19 +07:00
nntrivi2001 1fa52d1bfc feat(run): start a resume as soon as its session is picked
Picking a session in the setup form's resume picker only staged the
selection: the user still had to type a prompt and press Run before the
lane's tmux session was started with `--resume`. A resume carries its own
transcript, so there was nothing to type.

The picker now fires the start directly with the picked session (passed
explicitly, since the parent's state has not landed on that tick), sends the
session's own cwd — which is what the locked cwd field already displays —
and the Run button no longer requires a prompt while a resume is selected.
Fresh runs still require one.
2026-08-18 13:59:31 +07:00
nntrivi2001 ab6d6410d5 fix(workspace): keep the console cwd on the selected lane's own folder
The cwd was synced only when laneId changed, but a pane can render before
GET /api/lanes has answered — split view restores its pane lanes from
localStorage, and layout 1 paints before the list loads. With no lane
resolved the field fell back to the home suggestion, and since laneId never
changed afterwards it stayed there. RunSetup submits that string verbatim to
POST /api/lanes/:id/start, so the run was started in the wrong folder.

Track the cwd in its own effect keyed on the resolved lane path rather than
on laneId, so it re-syncs as soon as the lane is known. The home default now
applies only while no lane is selected at all.
2026-08-18 11:33:31 +07:00
nntrivi2001 37adf983e3 fix(workspace): bind each console pane to the lane it shows
LaneConsolePane kept handle/cwd/prompt/runHistory in local state that was
never reset when the laneId prop changed, so selecting another lane swapped
the header and detail panel while the terminal stayed attached to the
previous lane's tmux session.

Reset the pane on lane switch and re-attach immediately to the new lane's
live run from GET /api/run when it has one, falling back to that lane's
setup form when it does not. `lanes`/`activeRuns` are read through a ref so
the page's 5s poll cannot wipe a half-typed prompt.
2026-08-18 11:14:58 +07:00
nntrivi2001 c25008ab19 fix(run): stop Start/Resume from silently no-oping on an idle lane session
spawnRun adopted any existing `ccam-lane-<id>` tmux session without looking
at it, so a Resume issued while the session sat at a bare shell prompt (left
by `ccam lanes shell`, or by a `claude` that had already exited) dropped the
whole argv: no `--resume` ran, no initial prompt was typed, and the API still
answered 200. The lane's DB `run_id` is not set in that case, so the ERUNLIVE
guard in the start route never saw it either.

Reuse the pane instead of erroring: when the session exists and
`#{pane_current_command}` is a shell, type the argv into that pane and record
the run. A pane running a program (a live `claude`, an editor, a build) is
still adopted untouched, so attaching shows what is running rather than typing
over it. `sendCommand` POSIX single-quotes every argument and uses
`send-keys -l`, the only place in tmux.js that composes a command line.
2026-08-18 10:50:28 +07:00
nntrivi2001 174c650624 feat(run): show externally started Claude sessions in the active-runs list
The Workspace active-runs list only knew about runs this dashboard spawned,
so two `claude` sessions started by hand in terminal tabs showed up nowhere —
the list read "no active runs" while two agents were working.

Poll GET /api/sessions?status=active alongside the run list and merge those
sessions in as live rows, deduped against dashboard runs by session_id and
filtered to local sources with a cwd (a remote-source or cwd-less session
cannot be resumed on this machine).

External rows get no Attach action: the dashboard owns no tmux session for
them, so there is no PTY to bridge. They offer Resume, which reuses the
existing ensure-lane + start-with-resumeSessionId path to spawn a new
tmux-backed `claude --resume` in that folder — a second process on the same
transcript, not a view of the original terminal.
2026-08-18 09:49:03 +07:00
nntrivi2001 2c29504c75 test(lanes): stop lane-lifecycle from leaking real tmux + claude processes
Four cases in lane-lifecycle.test.js call /start without stubbing PATH,
so they spawn the real system `claude` binary in a real tmux session
to simulate a stuck/live run. Each then mocks tmux's own exec calls to
fake has-session/kill-session for the app's checks, but never touches
the real spawned process — the mock only fools the app, not the OS.
Two of these leaked past every prior test run undetected (ccam-lane-22,
ccam-lane-24), surfacing in the dashboard's live "Dashboard runs" list
with no DB record and a garbage started_at, and reappearing in a
Workspace split pane pointed at a deleted temp directory.

Stub a lightweight fake `claude` on PATH (same pattern already used
correctly elsewhere in this file) instead of spawning the real CLI, and
explicitly kill the real tmux session in each test's teardown since the
app-level mock never reaches the OS process.
2026-08-14 17:28:54 +07:00
nntrivi2001 39572aa04c fix(workspace): stop the pipeline map from overflowing the detail panel
The lane-detail section is a flex-1 row item next to the lane list, but
lacked min-w-0. A flex item's intrinsic min-width defaults to its
content size, so PipelineMap's 16-node row (which relies on flex-1
min-w-0 truncate per node to shrink) pushed the whole panel wider than
its allotted space instead of compressing, spilling nodes off-screen.
2026-08-14 17:08:09 +07:00
nntrivi2001 3ae0d00b0c fix(workspace): stop false stage-mismatch warning, cap info panel height
Undeclared lanes default stage to the DB sentinel "idle", which never
matches a pipeline node — skip the mismatch warning in that case
instead of showing a false lane-action-failed banner. Also cap the
expandable lane-info block so it can't squeeze the console/split-view
out of the fixed-height detail panel.
2026-08-14 16:56:52 +07:00
nntrivi2001 a2b5fa4669 fix(workspace): move lane list to a vertical column beside detail panel
The detail panel (LaneCard/PipelineMap/proof gallery/console) could
grow tall enough to visually crowd out the lane-strip carousel above
it once a run was attached in split view. Move the lane list into its
own scrolling vertical column beside the detail panel instead of
stacking it above, so neither can cover the other; also collapse the
info block by default in 2/4-pane split view (toggle to expand) and
move the pane-count control into the detail header.
2026-08-14 16:39:15 +07:00
nntrivi2001 bab19e2f36 docs(lanes): document the Workspace split terminal view 2026-08-14 13:42:13 +07:00
nntrivi2001 6f22aed47c refactor: dedupe split-view render, strengthen persistence test
- Extract ConsoleArea helper to eliminate ~60 lines of duplicated layout
  toggle + grid rendering shared between currentLane and !currentLane branches
- Fix persistence test to validate the component's actual write path instead
  of manually re-seeding localStorage (proves writeSplitViewState is called)
- Remove orphaned grid/pane code left by incomplete merge
2026-08-14 13:36:02 +07:00
nntrivi2001 d542fbbf4b fix: satisfy strict null checks in new split-view tests
Use proper guards before indexing getAllByTestId results. Store the array
in a variable first and assert the element is defined before using it, to
satisfy noUncheckedIndexedAccess strictness.
2026-08-14 13:26:26 +07:00
nntrivi2001 764dc6a7b5 test(workspace): add split terminal view tests with corrected expectations
Added comprehensive tests for the split-terminal-view feature:
- Default 1-pane layout (no multi-pane UI)
- Switching to 2-pane and 4-pane layouts
- Persisting layout and lane selections to localStorage
- Fallback behavior when persisted lane IDs no longer exist

Fixed test expectation: when a persisted lane ID no longer exists and falls back
to unselected (null), both panes render as empty (pane-empty), not just one.

Updated Run snapshot to reflect the new layout toggle buttons.
2026-08-14 13:17:25 +07:00
nntrivi2001 06817b7901 feat(workspace): add 1/2/4-pane split terminal view toggle 2026-08-14 12:55:37 +07:00
nntrivi2001 14f116bf00 fix: keep RunSetup reachable with zero lanes, fix snapshot handling
Fixes regression from Tasks 1-2:
1. Run.defaultCwd.test.tsx was failing because LaneConsolePane wasn't
   rendering when no lane was selected (in layout-1 with zero lanes).
2. Added fallback rendering of LaneConsolePane in Workspace when
   !currentLane, so RunSetup stays reachable on fresh install.
3. Fixed early-return condition in LaneConsolePane to only show
   dropdown-only placeholder when showLaneSelector=true && laneId=null
   (split-view mode), not in layout-1 mode.
4. Added useEffect to sync cwd state with defaultCwd when it changes
   asynchronously from parent.
5. Updated LaneConsolePane tests to reflect correct behavior.
6. Regenerated snapshot to match layout-1 output.
2026-08-14 12:32:49 +07:00
nntrivi2001 6dda604362 refactor(workspace): fix viewportLocked regression and wire run status callback 2026-08-14 12:11:36 +07:00
nntrivi2001 22ce61bcfe refactor(workspace): fix viewportLocked regression and wire run status callback 2026-08-14 12:09:46 +07:00
nntrivi2001 8a61a2b359 fix: restore live/offline indicator dropped during Header extraction
The wsConnected prop was destructured but unused. This restores the
live/offline status pill next to the page title, matching the original
Header function design and consuming the prop properly.
2026-08-14 11:42:32 +07:00
nntrivi2001 b1d43bf098 feat(workspace): extract LaneConsolePane from the inline run console 2026-08-14 11:36:38 +07:00
nntrivi2001 11b779479d fix: add missing copyright header to splitViewStorage.test.ts 2026-08-14 11:28:43 +07:00
nntrivi2001 18a1ecb6f9 feat(workspace): add localStorage helper for split-view layout state 2026-08-14 11:25:37 +07:00
nntrivi2001 fa416b5e6b docs(plan): add implementation plan for split terminal view
Task-by-task plan extracting LaneConsolePane from Workspace.tsx and
adding a 1/2/4-pane layout toggle with localStorage persistence.
2026-08-14 11:19:01 +07:00
nntrivi2001 0f15800b23 docs(spec): add split terminal view design 2026-08-14 11:09:33 +07:00
nntrivi2001 43f29ee904 fix(ccam-open): always rebuild the dashboard bundle
/ccam-open built only when client/dist was missing, so a stale bundle
silently outlived code fixes (seen 2026-08-13: fix b0bfc66 wasn't served
until a --force rebuild). Pass --force unconditionally.
2026-08-13 15:38:50 +07:00
nntrivi2001 18a42873b2 fix(mcp): fall back to lane cwd when source_repo is null
Adopted lanes have source_repo = null; lane-mcp.js passed it raw to
readSourceMcpServers and threw ENOMCPCONFIG. Match the existing fallback
pattern in lane-env.js/lane-profile.js.
2026-08-13 15:36:36 +07:00
nntrivi2001 b0bfc66d65 fix(workspace): auto-set cwd when a lane is selected from the strip
Selecting a lane card only updated selectedLaneId, leaving the run-setup
cwd field on whatever it was before — now selecting a lane also syncs
cwd to that lane's own working directory.
2026-08-13 13:55:51 +07:00
nntrivi2001 78fb82b257 fix(run): decode binary WS frames in TerminalView so pty output actually renders
Server streams PTY output as binary WS frames, but the browser's default
binaryType ("blob") handed onmessage a Blob that never matched the
`typeof === "string"` check — every keystroke response was silently
dropped and the terminal stayed blank despite the backend streaming
correctly (verified via a raw ws client against the live tmux session).
2026-08-13 13:54:19 +07:00
nntrivi2001 7e2bb6225f fix(run): forward text-frame keystrokes to pty and fix ws-pty upgrade dispatch
xterm.js's onData hands the browser a plain string, and WebSocket.send(string)
always emits a TEXT frame — pty-attach.js only forwarded BINARY frames to
pty.write(), so every keystroke was silently dropped as an unparseable JSON
control message. Now any non-control text frame reaches the pty.

Deeper root cause of the terminal never accepting input at all: the /ws
WebSocketServer used the {server, path} shorthand, whose own internal
upgrade listener calls handleUpgrade() for every upgrade on the shared
http.Server and aborts with 400 on a path mismatch — killing /ws-pty/*
upgrades before the PTY server's own listener ever ran. Switched /ws to
noServer + a manual path-checked dispatch, matching /ws-pty's pattern.
2026-08-13 11:14:18 +07:00
nntrivi2001 774ee48f19 fix(run): switch node-pty to @lydell/node-pty for the plugin's --ignore-scripts install path
node-pty ships prebuilt binaries for darwin/win32 only — on Linux it
needs a native build via its install script. The plugin's
--ignore-scripts install (scripts/plugin-bootstrap.js, deliberately
skipped to avoid requiring a build toolchain) silently left node-pty
unusable: server/lib/pty-attach.js's require() threw
"Cannot find module './prebuilds/linux-x64//pty.node'" the moment a
terminal was attached, leaving TerminalView permanently blank with no
visible error.

@lydell/node-pty is an API-compatible fork that ships each platform's
binary as a regular optionalDependency instead of a postinstall build
step, so a plain --ignore-scripts install resolves a working native
binding on Linux with no compiler needed. Verified by installing with
the exact `npm install --omit=dev --ignore-scripts` invocation the
plugin bootstrap uses and confirming require() succeeds.
2026-08-13 08:54:42 +07:00
nntrivi2001 bcd1259ed2 Merge branch 'worktree-tmux-terminal-run' 2026-08-12 16:39:51 +07:00
nntrivi2001 251a18dc39 docs: add README.vi.md Vietnamese translation, document pipeline picker
Adds README.vi.md as the Vietnamese translation of README.md, and
documents the pipeline-template picker/CLI switch (ccam lanes pipeline)
added in the previous feature.
2026-08-12 16:37:59 +07:00
nntrivi2001 d581705eb0 fix(workspace): restore View button functionality and clean up dead props
Fix two issues identified in code review:

1. Critical: onViewFromHistory was a silent no-op. Now navigates to the
   SessionDetail page using the same route pattern as the external link in
   RunHistory, allowing users to view a finished run's transcript.

2. Important: Removed dead slashCommands={[]} prop from RunSetup invocation.
   Made slashCommands optional in RunSetupProps to maintain type safety while
   reflecting that the discovery logic was removed.

All tests pass (396 client, 1152 server).
2026-08-12 16:20:02 +07:00
nntrivi2001 cb8800fa31 fix(client): clean up leftover pre-TerminalView dead code and type errors in Workspace.tsx
`npm run test:client` (Vitest/esbuild) doesn't type-check, so several
tasks' incomplete cleanup of the old RunConsole-era code in
Workspace.tsx (SlashCommand/BUILTIN_SLASH_COMMANDS references, old
RunStatus values, a stray `mode` field, a wrong `prompt` vs
`initialPrompt` key) went unnoticed through every review until `tsc
--noEmit` was run directly. Also fixes a stale `RunStatusPayload.exitCode`
read in Tabby's brain.ts and dangling RunStreamPayload/RunInputAckPayload
references left in types.ts.
2026-08-12 16:09:43 +07:00
nntrivi2001 9c3331c843 docs: fix Task 12 Round 1 accuracy findings
- Fix POST /api/run param: change 'prompt' to 'initialPrompt' (Finding 1)
- Fix POST /api/run response shape: show actual fields from publicRun() (Finding 2)
- Delete stale 'Running and releasing lanes' section with old POST /api/lanes/:id/start (Finding 3)
- Delete stale POST /api/lanes/:id/message section — endpoint now returns 400 EUNSUPPORTED (Finding 4)

All changes verified against actual code:
- server/routes/run.js:285 uses body.initialPrompt
- server/lib/pty-run.js:144-156 publicRun() returns {id, laneId, status, cwd, model, ...}
- server/routes/lanes.js:1134 message action returns 400 error
2026-08-12 15:48:10 +07:00
nntrivi2001 31af7aefbf docs: update Run feature docs for the tmux+PTY terminal (was stream-json)
- README.md: replace stream-json bullet with tmux+PTY description
- ARCHITECTURE.md: update /run Workspace page section to reflect PTY transport, xterm.js, and ccam lanes shell
- docs/API.md: replace stream-json endpoint docs with /api/run/*+/ws-pty/:runId PTY endpoint docs
- docs/LANES.md: add "Attaching a real terminal to a lane" section for ccam lanes shell; update lane "start" action docs and /run Workspace description
- Remove stale references: run_stream, run_input_ack, conversation/headless modes, RunConsole components
2026-08-12 15:35:57 +07:00
nntrivi2001 6709f9a192 feat(cli): add 'ccam lanes shell' to attach a real terminal to a lane's tmux session 2026-08-12 14:15:37 +07:00
nntrivi2001 b951f64321 fix(run): plumb the recorded prompt back into live runs, fix lane routing when the cwd doesn't match the selected lane
- pty-run.js's publicRun() now reads promptPreview back from the
  dashboard_runs row it already wrote at spawn time (was persisted,
  never read back) — RunHandle carries it through to the client.
- Workspace.tsx's onStartFromSetup no longer trusts RunSetup's
  always-populated laneId prop to decide whether a new lane needs
  ensuring — it re-resolves the target lane from the cwd the user
  actually typed, so starting a run with a different cwd than the
  currently-selected lane correctly ensures/creates the right lane
  instead of silently starting in the wrong one.

Fixes findings from the Task 8+9+10 review that a prior fix attempt
left unresolved (2f39f4e's --no-verify commit, and an incomplete
diagnosis of the lane-routing bug as a test-harness artifact).
2026-08-12 13:18:04 +07:00
nntrivi2001 2f39f4ec98 feat(run): wire Workspace to TerminalView, delete the stream-json Run feature
Combines three tasks that couldn't land as separate commits: the
pre-commit hook's full test run crashes on any intermediate state
where Workspace.tsx still imports the files being deleted, so the
deletion (old RunConsole/useRunStream/run-spawner/stream-json-parser),
the RunSetup/RunHistory type adjustments, and this file's own
TerminalView wiring had to be staged together and committed as one
hook-passable unit.

- Delete RunConsole.tsx, useRunStream.ts, server/lib/run-spawner.js,
  server/lib/stream-json-parser.js and their tests (Task 8).
- Adjust RunSetup.tsx/RunHistory.tsx to the tmux-backed RunHandle/
  RunStartArgs/DashboardRunHistoryItem shapes, remove mode selection
  UI (Task 9).
- Swap Workspace.tsx's chat-bubble run console for TerminalView
  (xterm.js over /ws-pty/:runId), drop the stream-json envelope
  plumbing, update Start/Resume to the new RunStartArgs payload.
  Create onStartFromSetup handler to work with RunSetup's new callback
  shape. Remove mode state and related plumbing. Remove send/followUp
  state (no longer using old RunConsole chat interface).
- Add promptPlaceholderTerminal i18n key to support RunSetup's new
  placeholder text (Task 10).
- Update Workspace.test.tsx to mock TerminalView component.
- Regenerate screens.snapshot.test.tsx snapshot (only Workspace run
  panel changes: terminal container instead of chat bubbles).
2026-08-12 11:58:38 +07:00
nntrivi2001 f1e7d4245a test(lanes): add test for stale run_id clearing during healing
Add missing test coverage for healRunId's core behavior: that a STALE run_id
(tmux session gone) gets CLEARED to null with status: idle when read via GET.
The existing test only verified the LIVE case (session still running). This test
proves the release-on-gone path, simulating a session death via tmux mock.
2026-08-12 11:08:21 +07:00
nntrivi2001 82bf803c2e fix(lanes): bridge routes/lanes.js to pty-run.js
Replace run-spawner imports and APIs with pty-run:
- Import pty-run instead of run-spawner
- Delete setRunExitHandler registration, replace with read-time self-heal in payload()
- Remove mode validation (mode no longer exists in pty-run)
- Update spawnRun call to use new parameter names (initialPrompt, not prompt/mode)
- Replace "message" action with explicit 400 EUNSUPPORTED response
- Fix stopLaneRun to poll on status !== "gone" instead of !actualExitedAt

Adapt tests to tmux-based run model:
- Delete tests about mode-specific behavior (removed feature)
- Rewrite lane release tests using tmux.__setExecImpl mocks instead of withFakeClaude
- Update assertions to check status === "gone" instead of specific exit codes
- Update ERUNTIMEOUT test to mock tmux sessions instead of child processes

All lane-related tests pass; only pre-existing port conflicts in lane-detect.test.js remain.
2026-08-12 10:53:23 +07:00
nntrivi2001 24f13911fe feat(run): replace RunHandle/RunStartArgs types and api.run for the tmux backend 2026-08-12 10:24:45 +07:00
nntrivi2001 9b8d9bbe39 feat(run): add TerminalView xterm.js component for the PTY transport
- TerminalView.tsx: xterm.js component with WebSocket attachment to /ws-pty/:runId
- Test: validates WS connection URL, incoming terminal data, and outgoing keystrokes
- Added ResizeObserver stub to test-setup.ts for jsdom environment
2026-08-12 10:19:10 +07:00
nntrivi2001 872c698132 feat(run): add /ws-pty/:runId PTY transport bridging WS to tmux attach 2026-08-12 10:13:14 +07:00
nntrivi2001 1bc237198c feat(run): rewrite routes for the tmux backend, drop stdin-message endpoint 2026-08-12 09:46:55 +07:00
nntrivi2001 56744b360d feat(run): add tmux-backed run lifecycle (spawn/kill/list computed from tmux state) 2026-08-12 09:38:25 +07:00
nntrivi2001 1dd18fe98c feat(run): add tmux command wrapper with an injectable exec seam 2026-08-12 09:34:18 +07:00
nntrivi2001 d96d552428 chore: add node-pty/xterm deps, tmux in Docker, dashboard_runs.tmux_session column 2026-08-12 09:29:44 +07:00
nntrivi2001 00f6338d4c docs: bring plan and spec into the tmux-terminal-run worktree
These were committed on main's local history but this worktree
branched from origin/main, which doesn't have them yet — copying the
files in so subagent-driven-development has a plan to read from this
branch.
2026-08-12 09:25:34 +07:00
nntrivi2001 dfea1a99d6 fix(tests): scrub GIT_* env vars leaking from the pre-commit hook into git-fixture tests
server/lib/update-check.js's execGit() and two test helpers
(lanes-cli.test.js, update-check.test.js) shelled out to git with an
explicit `cwd` but no `env` override. A parent git hook process (this
repo's own .husky/pre-commit, which runs `npm run test:server`) sets
GIT_DIR/GIT_INDEX_FILE in its own environment; those leak to every
child process and take precedence over `cwd` for repo discovery, so
every git command these tests ran against their throwaway tmp repos
was silently redirected at the real repo running the hook instead —
reproduced firsthand as four foreign "init"/"fixture" commits
overwriting a worktree branch mid pre-commit run. Fixes it the same
way server/lib/worktree.js already documented and did for its own git
calls: strip the GIT_* vars before exec.
2026-08-12 09:24:03 +07:00
nntrivi2001 f4dc6a0730 docs(spec): design real tmux+PTY terminal to replace stream-json Run
Terminal-started sessions can never be controlled from the dashboard
(hooks are one-way, no stdin channel into an externally-spawned
process) — tmux is the only mechanism that gives a second attached
client real two-way control. Replaces the stream-json/chat-bubble Run
feature entirely with a real tmux-backed PTY streamed via xterm.js,
while leaving hook-derived session/agent data (already synced
independent of the run mechanism) untouched.
2026-08-11 17:32:47 +07:00
nntrivi2001 7357070fb9 chore: remove unused desktop app, cloud deployment infra, and monitoring stack
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/
Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana
stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml,
their npm scripts, and every dangling reference across README,
ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file
MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics
endpoint itself is untouched — it's the dashboard's own route, not
part of the removed monitoring stack.
2026-08-11 12:16:54 +07:00
nntrivi2001 f0ae876472 feat(lanes): add pipeline-template picker to the Workspace lane header
Lets a lane's pipeline template be switched live from the dashboard
(the same PATCH /api/lanes/:id the CLI's `ccam lanes pipeline` uses),
so lanes created before the picker shipped don't need the terminal.
Both ship-feature skills now force their own template before their
first `ccam stage` call, so the human never has to pick correctly at
lane creation.
2026-08-11 12:15:16 +07:00
nntrivi2001 f6946d72f4 docs(plugins): fix inaccurate uninstall cleanup instructions
Verified against a real claude plugin uninstall: it only drops the plugin
from the enabled list. The server keeps running, the cached source stays on
disk, and the hook entries claude plugin install wrote into settings.json
are left behind pointing at the now-uninstalled cache dir — silently fails
once Claude Code eventually GCs it. The previous instructions ("uninstall
removes the hooks and the cached source") were untested assumptions; this
adds the missing settings.json cleanup step.
2026-08-10 16:56:40 +07:00
nntrivi2001 205f40c29c fix(plugins): stop stripLegacyHooks from deleting the plugin's own hooks
claude plugin install materializes the plugin's inline hooks into
~/.claude/settings.json itself, with \${CLAUDE_PLUGIN_ROOT} resolved to the
actual cache path — confirmed by installing the plugin for real and
inspecting the file. Those entries also contain "hook-handler.js", so
isOurEntry()'s plain substring match could not tell a legitimate
plugin-installed hook from a leftover npm run install-hooks entry: every
SessionStart would have stripped the plugin's own working hooks right back
out. isCheckoutHookEntry() only removes entries whose command does NOT
resolve under ~/.claude/plugins/cache/. plugin-doctor.js's duplicate-hook
count uses the same predicate.
2026-08-10 16:28:59 +07:00
nntrivi2001 a65ee1512e fix(plugins): serverIsLive() falsely reported a server as running
resolveAllDashboardPorts() falls back to [DEFAULT_PORT] when the discovery
file has no live entry — a reasonable guess for the CLI/hook handler, but
wrong for the bootstrap's own liveness check: with no server running at all,
the bootstrap believed one was already up and never called startDashboard(),
confirmed against a real plugin install where the dashboard never started.
plugin-doctor.js's "Server" row had the same bug. Both now read the discovery
file directly and check PID liveness via the new liveServers() (livePids()
reused it instead of duplicating the read).
2026-08-10 16:22:13 +07:00
nntrivi2001 022b2384ac fix(plugins): plugin.json repository field must be a string
Claude Code's manifest schema requires repository as a URL string, not the
{type,url} object form — the marketplace install failed validation
("expected string, received object") the first time it was tried against a
freshly pushed marketplace.
2026-08-10 16:15:06 +07:00
nntrivi2001 8a82895c65 feat(plugins): make CCAM installable straight from a Claude Code plugin
Adds a root `ccam` plugin (`.claude-plugin/plugin.json`, `"source": "./"`) so
`/plugin marketplace add` + `/plugin install ccam@...` is enough on a machine
with nothing but Claude Code: no clone, no npm run setup, no manual npm start.

- scripts/plugin-bootstrap.js: SessionStart hook. Fast-path exit, Node >=22.5
  gate (node:sqlite), mkdir lock with stale reclaim, deps installed into
  ~/.claude/agent-dashboard/runtime/ (never the plugin cache), legacy
  checkout-hook cleanup (backed up), ~/.local/bin/ccam launcher, eager UI
  build so client routes like /run work immediately, detached server spawn.
- scripts/plugin-open.js, scripts/plugin-doctor.js: /ccam-open, /ccam-doctor.
- server/index.js: DASHBOARD_CLIENT_DIST override (plugin cache is read-only).
- mcp/build/ is committed (plugin MCP servers start before any bootstrap could
  build them) and kept honest by scripts/check-mcp-build.js (content hash,
  not mtime), enforced by pre-commit when mcp/src changes.
- plugins/ccam-dashboard/.mcp.json moved under plugins/ccam/ with a working
  ${CLAUDE_PLUGIN_ROOT} path (the old relative path never resolved from a
  marketplace-cached subdir).
- Docs: README, INSTALL, SETUP, ARCHITECTURE, CLAUDE.md, docs/PLUGINS.md,
  docs/MCP.md, docs/CLI.md, docs/HOOKS.md.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 16:05:37 +07:00
nntrivi2001 5a793e70cc fix(test): stop lane-runtime leaking a live server on every run
The two `upLane qc option` tests booted a real stack and then only released
the slot. `downLane` locates a service's pid file through the lane's slot
directory, so releasing the slot first orphaned the child with nothing left
able to reach it — one `python3 -m http.server` survived every run, holding
a port from a pool that is only ten wide. Thirteen had accumulated; the
eleventh run onwards fails with EPORTBUSY in whichever test boots next,
which reads as an unrelated flake.

Both tests now stop the stack before releasing the slot, and assert the port
went quiet — so a teardown that breaks again fails here rather than leaking
into the next run. A suite-level `after` covers the case a test throws
before its own teardown; it runs before SUITE_ROOT is removed, since the pid
files it needs live inside it.
2026-08-07 10:01:01 +07:00
nntrivi2001 201eae68bb fix(client): repair the TypeScript build
`npm run build` runs `tsc -b` first and it has been failing: `api.ts` used
`NamedLock` without importing it, and four lane test fixtures predate
`Lane.active_feature_id` / the widened `LaneRuntime`, so spreading a
`Partial<Lane>` over them no longer satisfied the required fields.

Nothing shipped could be rebuilt while this was red, which is how a client
change reaches a dashboard running in production mode. The fixture fixes are
casts with a note, not type relaxations — the base literals still list every
required field, so the assertion states what they already prove.
2026-08-07 09:52:13 +07:00
nntrivi2001 8fcef5a10b feat(lanes): let Add Lane choose the pipeline template
Creation is the only point the UI could ever set a lane's template, and it
never offered the choice — so every lane added from "+ Add lane" was born
on `default` and rendered an 8-node map for a 16-node workflow, with no
screen able to change it afterwards. That is the defect that made the
ship-feature template unreachable from the browser.

The modal now shows a *Pipeline template* select fed by
`GET /api/lanes/pipelines`, labelled with each template's node count so the
consequence of the choice is visible. A failed fetch degrades to a `default`
option rather than blocking lane creation.

`pipeline` was already accepted by `POST /api/lanes` but silently dropped by
`/ensure` and `/worktree`, which build their own createLane payloads; both
now pass it through, and both map `EBADPIPELINE` to 400 like `EBADCWD`.
2026-08-07 09:44:48 +07:00
nntrivi2001 67edda77eb feat(lanes): make the pipeline map track a skill's real progress
A lane's pipeline map only ever moved when a skill remembered to call
`ccam stage`, and the ship-feature template shipped with no detection rules
at all — so a lane driven by Superpowers skills sat at whatever stage it
last declared, and the `gates` node was never declared by anything.

Detection (`detect` rules on each node) now covers the Superpowers skill
invocations and the `ccam`/`gh` commands the ship-feature-lane skill
actually runs. It stays a safety net, not the mechanism: forward-only,
never `done`, never overriding a declaration. Two rules were deliberately
left out — `git diff` on `review` (this repo's own tests record it pinning
a lane at `review` on a real session) and anything on `merged`/`done`.

Stage vocabulary grows to 50 names over the same 16 nodes, following
Shipyard's PHASES shape: sub-states like `migration-collision`,
`e2e-scoped` and `gate-blocked` say WHY a lane sits on a node without the
map growing a node per reason. Every alias has a source — the skill
declares it, `default.json` uses it, or Shipyard's PHASES lists it.

Two silent failures fixed along the way:

- `lane.stages` is keyed by the raw declared string, so a stage declared
  under an alias lost its `--evidence` and rendered amber instead of
  green. `stageRecords` resolves each key onto its node.
- `ccam stage <typo>` stored fine and then rendered nowhere. It now warns
  on stderr while still exiting 0.

`ccam lanes pipeline` closes the gap that made all of this invisible: a
lane could only be assigned a template at creation, and no screen in the
web UI offers the choice, so every lane added from "+ Add lane" was stuck
on `default`'s 8 nodes. An unknown template id is now refused rather than
silently falling back to `default` on read.

Also merges the repo's own `ship-feature` skill into the Superpowers
workflow: it delegates planning/TDD/review/verification instead of
restating them, and declares a stage at each phase.
2026-08-07 09:34:20 +07:00
nntrivi2001 87b5e1c3db fix(lanes): make Add Lane mode segments fill their row and read as selected
The two-way segment used a translucent accent wash for the active state,
which at this size read as a hover tint rather than a selection. Solid
accent plus a shadow makes the choice unambiguous, and `flex-1` stops the
two segments from sizing to their label text.
2026-08-07 09:29:59 +07:00
nntrivi2001 61443f4814 chore(deps): resync package-lock license and funding with package.json
The lockfile still carried `MIT` and a `funding` URL pointing at an
unrelated sponsors page, both left over from the template this project was
scaffolded from. package.json declares `UNLICENSED` and no funding.
2026-08-07 09:28:28 +07:00
nntrivi2001 bd829ba1c3 fix(lanes): capitalize Vietnamese action-button labels, gitignore .ccam/
action.start/stop/clear/forget/purge/remove/reset in lanes.json (vi)
were lowercase (bắt đầu, dừng...) while every other button label in the
app capitalizes its first letter. Also gitignore /.ccam/ - the local
lane profile that appears in a repo's own working tree only when that
repo is adopted as its own lane (machine-specific runtime config, not
source).
2026-08-06 16:14:55 +07:00
nntrivi2001 78f6e1be8e feat(lanes): Add Lane repo/worktree mode toggle, manual branch, folder browse
- Repo mode adopts a directory as-is via /lanes/ensure (no worktree, no
  branch fields) - the right choice for a main repo you want stage
  detection on. Worktree mode (default) keeps the existing provisioning
  flow but now requires a manually-typed branch name instead of deriving
  one from the title.
- POST /lanes/worktree accepts an optional `branch`, validated via
  `git check-ref-format --branch`; omitting it preserves the CLI's
  existing auto-derived-branch behavior.
- New GET /lanes/browse lists a directory's immediate subdirectories,
  backing a small folder-browse modal on both path fields - browsers
  cannot expose an absolute path from a native picker, so this is
  server-backed instead, consistent with the tool's local-first model.
2026-08-06 16:03:49 +07:00
nntrivi2001 9f13769fb4 feat(lanes): surface child worktrees on a lane card + detect Superpowers skills
Adopting the main repo as its own lane now gets stage detection
(cwd matches, same as any other lane), and its card lists every
managed-worktree lane provisioned from it with a jump-to link.
Also add the two missing Skill-tool detect rules (implement, ship)
so detection covers all four Superpowers workflow phases, not just
plan/review.
2026-08-06 15:33:07 +07:00
nntrivi2001 54299f119e fix(lanes): wait for worktree provisioning before auto-setup
POST /worktree answers with 202 before the background git worktree
add finishes, so profileInit/agentsInstall/mcpSync were racing the
lane's own directory into existence and mostly failing. Poll
GET /api/lanes/:id until provisioning leaves "provisioning" first.
2026-08-06 14:28:21 +07:00
nntrivi2001 e31d261fd7 fix(lanes): don't auto-close Add Lane modal after setup summary
The 3s auto-close timer closed before a user reasonably had time to
look at the setup results, making the feature appear to do nothing.
Require an explicit dismiss (Cancel/X) instead.
2026-08-06 13:55:43 +07:00
nntrivi2001 7fa39687fe feat(lanes): show Add Lane auto-setup summary before closing modal
The three setup calls (profile/agents/mcp) fired after worktree
creation but their outcome was only logged to the console. Keep the
modal open with a ✓/✗ summary for a few seconds (or until dismissed)
so the user actually sees what happened, matching the original F5 design.
2026-08-06 13:31:56 +07:00
nntrivi2001 77f3805083 docs(lanes): document Add Lane auto-setup (F5) 2026-08-06 12:00:14 +07:00
nntrivi2001 dd0cb4ad7c feat(lanes): auto-setup (profile/agents/mcp) after Add Lane (F5) 2026-08-06 11:49:11 +07:00
nntrivi2001 5dd4793b88 feat(lanes): add POST /:id/profile/init route (F5) 2026-08-06 11:42:09 +07:00
nntrivi2001 64c00dea85 docs(lanes): plan F5 — auto-setup after Add Lane (E)
3 tasks: POST /:id/profile/init route (wraps already-built
lane-detect.js), AddLaneModal.tsx wiring (Promise.allSettled over
profile-init + agents-install + mcp-sync, none blocking lane creation
or each other) + client API method + tests, and docs.
2026-08-06 11:35:36 +07:00
nntrivi2001 ab6005afdc docs(lanes): design F5 — auto-setup after Add Lane (E)
One click instead of four: after AddLaneModal creates a worktree lane,
fire profile-init + agents-install + mcp-sync in parallel
(Promise.allSettled, best-effort — none of the three blocks lane
creation or each other). Needs one new route (POST
/:id/profile/init, wrapping already-built lane-detect.js) since
profile-init was CLI-only until now.
2026-08-06 11:31:29 +07:00
nntrivi2001 14dbfa73e5 test(lanes): cover F4's LaneCard additions + regenerate screens snapshot 2026-08-06 10:30:17 +07:00
nntrivi2001 57dd4ffcb7 feat(lanes): add Lanes section (skills install, housekeeping) to Settings (F4) 2026-08-06 10:22:58 +07:00
nntrivi2001 6d8c5399ea feat(lanes): add agents-install/mcp-sync/integration/sync-check to LaneCard (F4) 2026-08-06 09:59:33 +07:00
nntrivi2001 bc7c5e5431 feat(lanes): add client API methods for F4 lane actions (F4) 2026-08-06 09:47:10 +07:00
nntrivi2001 e32c684bf0 feat(lanes): add POST /api/lanes/gc route (F4) 2026-08-06 09:31:42 +07:00
nntrivi2001 e7ef7bcef9 feat(lanes): extract skills-install lib, add POST /api/skills/install (F4) 2026-08-06 09:26:43 +07:00
nntrivi2001 99465d2095 docs(lanes): plan F4 — lane actions UI (LaneCard + Settings)
6 tasks: skills-install lib extraction + route, gc route, client API
methods, LaneCard additions (agents install/mcp sync/integration
badges/sync-base check — read-only, no merge button), Settings
additions (skills install + housekeeping), and test coverage
(LaneCard.test.tsx cases + screens snapshot regen).
2026-08-06 09:17:37 +07:00
nntrivi2001 5f114d7c6f docs(lanes): design F4 — surface E1-F3c lane actions in the UI
LaneCard gets agents-install/mcp-sync buttons, integration status
badges, and a sync-base --check button (read-only preflight only — no
merge button, that stays a session/skill action). Settings gets
skills-install and housekeeping (gc) buttons, machine-wide. Two new
routes needed (POST /api/skills/install, POST /api/lanes/gc) since
those two primitives were CLI-only until now.
2026-08-06 09:09:30 +07:00
nntrivi2001 d0f42254ee docs(lanes): document ccam lanes gc (E, F3c) 2026-08-05 17:46:42 +07:00
nntrivi2001 5e620bd8ab feat(lanes): add ccam lanes gc — orphan MCP reap + log capping (E, F3c)
Ports the two pieces of Shipyard's lane-gc.sh that match CCAM's actual
architecture: kill Playwright MCP processes reparented to pid 1 (owning
session died), cap hook logs over 10MB back to their last 2MB in place.
Drops auto-removing stale worktrees by age (conflicts with the
never-automatic-destroy rule), state archiving, and scratch-debris
sweep (different storage architecture / files CCAM doesn't generate) —
see docs/superpowers/specs/2026-08-05-lane-gc-design.md.
2026-08-05 17:44:46 +07:00
nntrivi2001 e2d516f199 docs(lanes): design F3c — ccam lanes gc (E)
Scopes lane-gc.sh down to the two pieces that match CCAM's actual
architecture (orphan Playwright MCP reap, oversized-log capping).
Drops auto-removing stale worktrees by age — that's exactly the kind
of automatic destructive action this repo's own CLAUDE.md forbids
(destroy always goes through the three-check guard, never automatic).
State archiving and scratch-debris sweep don't apply either (different
storage architecture; CCAM doesn't generate those files).
2026-08-05 17:38:04 +07:00
nntrivi2001 f157977783 docs(lanes): document ccam skills install (E, F3b) 2026-08-05 17:19:11 +07:00
nntrivi2001 6d24f9d7ad feat(lanes): add ccam skills install (E, F3b)
Copies .claude/skills/ship-feature-lane/ into ~/.claude/skills/, so
/ship-feature-lane is discoverable from a session running inside any
lane's own working directory — not just inside this repo, which is
where it lived until now (Claude Code only auto-discovers .claude/
directories from the repo that owns them). Pure filesystem action,
same self-location REPO_ROOT already gives lanes profile init.
2026-08-05 17:16:38 +07:00
nntrivi2001 c781fcdb45 docs(lanes): design F3b — ccam skills install (E)
Found auditing E1-F3a for Shipyard parity: .claude/skills/ship-feature-lane/
only exists inside ccam-lanes' own repo, never installed globally, so
/ship-feature-lane is unreachable from its actual intended invocation
context (a session inside some OTHER lane's repo). Shipyard's own
install-claude-assets.sh solves this for its bundled skills the same way.
2026-08-05 17:12:37 +07:00
nntrivi2001 0006fabb4c docs(lanes): wire ship-feature-lane to the real integration toggle check (F3a) 2026-08-05 16:37:28 +07:00
nntrivi2001 32dc4e432a feat(lanes): add ccam lanes integration CLI + route (F3a) 2026-08-05 16:30:23 +07:00
nntrivi2001 8fc7a32490 feat(lanes): add isIntegrationEnabled toggle check (F3a) 2026-08-05 16:12:58 +07:00
nntrivi2001 36d66cda07 docs(lanes): plan F3a — ccam lanes integration toggle reader (F)
3 tasks: isIntegrationEnabled in lane-profile.js (reuses parseEnvFile),
the route + CLI, and wiring SKILL.md's hardcoded-off Setup text to the
real check (still no-op behaviorally — no ticketer/dev-qc agent exists
to act on an enabled toggle yet, but the check itself is now honest).
2026-08-05 16:07:33 +07:00
nntrivi2001 4b3a1e3d25 docs(lanes): design F3a — ccam lanes integration toggle reader (F)
Scopes down F3 to just the generic <name>_ENABLED toggle-check primitive
(ccam lanes integration <name>) — ticketer/dev-qc agents stay deferred,
same reasoning E3 already used for qc-local's credential gap: no tracker/
dev-QC MCP configured anywhere, no per-lane credential source to consume.
2026-08-05 16:04:39 +07:00
nntrivi2001 ef686454b9 docs(lanes): document ccam lanes mcp sync (F1) 2026-08-05 15:39:04 +07:00
nntrivi2001 60fb7cbaeb feat(lanes): add ccam lanes mcp sync CLI (F1) 2026-08-05 15:34:15 +07:00
nntrivi2001 8471b3a757 feat(lanes): add POST /:id/mcp/sync route (F1) 2026-08-05 15:28:31 +07:00
nntrivi2001 2731b7c8ce feat(lanes): add lane-mcp sync core (F1) 2026-08-05 15:20:25 +07:00
nntrivi2001 d25a20089a docs(lanes): plan F1 — ccam lanes mcp sync (F)
4 tasks: the lane-mcp.js sync core (relocate + pin + seed profiles, no
permission/settings writes per the design spec's scope decision), the
route, the CLI, and docs.
2026-08-05 15:14:31 +07:00
nntrivi2001 4da7ef0453 docs(lanes): design F1 — ccam lanes mcp sync (F)
Scopes the first of F's four pieces to .mcp.json relocation + Chromium
profile seeding only. Drops Shipyard's permission/auto-approval writes
(no existing CCAM pattern for a backend command granting permissions,
and the rules named agents not in scope: pr-reviewer has no driving
skill to port, ticketer is a later task) and the node-version wrapper
(environment-specific workaround, no evidence this repo needs it).
2026-08-05 15:10:42 +07:00
nntrivi2001 f6fa1ea42d fix(lanes): SKILL.md — unblock Stage 6/7, add PR-base guard at Stage 12 (E1)
Two gaps found via audit against Shipyard's source:

1. Stage 6/7 still said "this agent does not exist yet" and told a
   driving session to treat any lane reaching those stages as blocked —
   stale since E3 shipped qc-local + senior-gate-reviewer. Replaced with
   an unconditional launch plus a one-time "ccam lanes agents install"
   preflight note in Setup.

2. Stage 12's conflict-resolution path called `ccam lanes sync-base`
   directly on any CONFLICTING PR, with no check that the PR's base is
   actually `development` first. Shipyard's original has this guard
   (its own "legacy main-based PR" case) — ported here in general form:
   never auto-merge into a PR whose base drifted from `development`.
2026-08-05 14:40:57 +07:00
nntrivi2001 d2327408fb fix(lanes): mergeSync commits a fully rerere-auto-resolved merge instead of rethrowing (E2)
Root cause: the catch block only fell through to the auto-commit path when
`unmergedFiles().length && MERGE_HEAD exists` — but a merge rerere resolved
completely has ZERO unmerged files (git already staged the resolution), so
that guard was always false and the raw git error was rethrown instead.
Found via an audit against the Shipyard source this was ported from.

Fixed by checking MERGE_HEAD first (unconditionally — its absence means the
merge never started, a real failure), then branching on whether any files
are still unmerged. Added a real rerere fixture test (teach a resolution,
recreate the identical conflict, confirm mergeSync auto-commits) — the
existing test suite had no coverage for this path.
2026-08-05 14:39:02 +07:00
nntrivi2001 02b90292c0 docs(lanes): document ccam lanes agents install (E3) 2026-08-05 12:52:42 +07:00
nntrivi2001 727823d75e feat(lanes): add ccam lanes agents install CLI (E3) 2026-08-05 12:46:56 +07:00
nntrivi2001 3fb70a3fc1 feat(lanes): add POST /:id/agents/install route (E3) 2026-08-05 12:42:27 +07:00
nntrivi2001 4134b3d307 feat(lanes): add lane-agents install core (E3) 2026-08-05 12:37:40 +07:00
nntrivi2001 692ba65b0f feat(lanes): add qc-local + senior-gate-reviewer agent templates (E3) 2026-08-05 12:29:59 +07:00
nntrivi2001 39c6e1c920 docs(lanes): plan E3 — agents port (qc-local + senior-gate-reviewer) (E)
5 tasks: the two ported agent templates (every Shipyard placeholder/script
reference resolved at port time), the lane-agents.js install core (git-dir
vs git-common-dir correctness verified against a real worktree fixture,
same lesson E2 already learned for info/attributes), the route, the CLI,
and docs.
2026-08-05 11:52:28 +07:00
nntrivi2001 7606653537 docs(lanes): design E3 — agents port (qc-local + senior-gate-reviewer) (E)
Scopes the third of E's remaining pieces to the two agents the current
skill text actually invokes (Stage 6/7). ticketer/dev-qc (F-gated) and
pr-reviewer (unreferenced) stay out of scope; per-lane credential
embedding is deferred pending a seed-account system this repo doesn't
have yet.
2026-08-05 11:46:28 +07:00
nntrivi2001 ed884c6961 docs(lanes): document ccam lanes sync-base (E2) 2026-08-05 10:59:02 +07:00
nntrivi2001 5a127df709 feat(lanes): add ccam lanes sync-base CLI (E2) 2026-08-05 10:52:36 +07:00
nntrivi2001 40b1d0e0fb feat(lanes): add POST /:id/sync-base route (E2) 2026-08-05 10:47:02 +07:00
nntrivi2001 d12e9410e3 feat(lanes): add lane-sync core — check/merge/continue (E2) 2026-08-05 10:36:33 +07:00
nntrivi2001 a4246528fe feat(lanes): add MIGRATIONS_DIR + GENERATED_MERGE_PATHS profile declarations (E2) 2026-08-05 10:30:41 +07:00
nntrivi2001 77d79a59d7 docs(lanes): plan E2 — ccam lanes sync-base (E)
5 tasks: MIGRATIONS_DIR/GENERATED_MERGE_PATHS profile declarations, the
lane-sync.js git core (check/merge/continue, ported against a real
bare-origin fixture mirroring lane-sync-dev.sh's own test suite, plus a
dedicated git-worktree fixture to catch the git-dir vs git-common-dir
distinction MERGE_HEAD/info-attributes depend on), the sync-base route,
the CLI subcommand, and the SKILL.md/docs edits that turn three "if it
exists yet" conditionals into real instructions.
2026-08-05 09:49:56 +07:00
nntrivi2001 ea0cd3b455 docs(lanes): design E2 — ccam lanes sync-base (E)
Scopes the second of E's remaining pieces: migration-collision preflight,
keep-ours merge driver for generated files, and the single sanctioned
origin/development-into-feature-branch merge. Agents and F's integrations
stay out of scope, per the roadmap's own subsystem split.
2026-08-05 09:38:55 +07:00
nntrivi2001 80d49e2c24 docs(lanes): document ship-feature-lane skill + --qc flag (E1) 2026-08-04 18:13:06 +07:00
nntrivi2001 9377849ae0 feat(lanes): port ship-feature-lane skill (Stages 0-14, E1) 2026-08-04 18:02:45 +07:00
nntrivi2001 e600df236e feat(lanes): add ship-feature pipeline template (E1) 2026-08-04 17:56:38 +07:00
nntrivi2001 31f984c750 feat(lanes): add --qc boot flag + QC_BOOT_ENV for deterministic QC stacks (E1) 2026-08-04 17:51:03 +07:00
nntrivi2001 c37933adfe docs(lanes): plan E1 — ship-feature pipeline template + skill port
4 tasks: --qc boot flag + QC_BOOT_ENV, pipeline template JSON, the ported
skill text (Stages 0-14, integrations hardcoded off pending F), and a dry
run + docs. Corrected the pipeline-template task against the real node
schema (id/label/icon/gate/aliases, not the detect.stage sketch) and the
real getPipeline never-throws behavior during self-review.
2026-08-04 17:44:50 +07:00
nntrivi2001 b08504858c docs(lanes): design E1 — ship-feature pipeline template + skill port
Scopes the first of E's four independent pieces: --qc boot flag +
QC_BOOT_ENV, the pipeline template, and the ported skill text. Agents,
sync-base, and F's integrations are named but deliberately out of scope,
per the roadmap's own subsystem split.
2026-08-04 17:37:33 +07:00
nntrivi2001 5d5ea32db6 docs(lanes): document proof gallery (C) 2026-08-04 16:41:56 +07:00
nntrivi2001 6cb5fb356c fix(lanes): make proof gallery test assertion actually fail on missing panel 2026-08-04 16:34:44 +07:00
nntrivi2001 fccfa5ad17 feat(lanes): show proof gallery panel in the Workspace page (C) 2026-08-04 16:25:09 +07:00
nntrivi2001 a545230746 feat(lanes): add ccam lanes proof-link CLI (C) 2026-08-04 16:13:02 +07:00
nntrivi2001 5de66d0b13 feat(lanes): expose GET/DELETE /api/lanes/:id/proof + proof-link over proof.js (C) 2026-08-04 16:04:47 +07:00
nntrivi2001 e6942db7fe feat(lanes): add proof gallery core (list/file/delete/link) (C) 2026-08-04 15:55:04 +07:00
nntrivi2001 e9193ae4be docs(lanes): add plan docs for B, D, C (per-feature state, named locks, proof gallery)
B and D plans were written and executed but never staged. C is new, not yet implemented.
2026-08-04 15:47:18 +07:00
nntrivi2001 fcc7a8f2f3 fix(lanes): stop archive/activate from clobbering feature title and links
archiveActiveFeature was overwriting a feature's own title with the
LIVE lane's title on every clear/switch — a --title set on activation
silently disappeared. activateFeature restored stage/status/gate/CI/
stages/notes onto the live lane when switching back to a past feature,
but never links, so they vanished on reactivation. Both are fixed, with
a regression test for each (full activate/archive/reactivate round trip
for links).

Also fixes server/routes/lanes.js: the doc comment explaining GET
/:id/git's rationale had been left sitting above the newly-inserted
/:id/features routes instead of its own route.

docs/API.md's Lane features section described fields and behavior that
don't exist in the real routes (an "active" boolean, a POST response
containing "archivedPrevious", a "409 ESTALE" concurrency response) —
rewritten to match the actual request/response shapes exactly.
2026-08-04 15:17:14 +07:00
nntrivi2001 917f0794d5 docs(lanes): document per-feature state and archive (B) 2026-08-04 15:06:05 +07:00
nntrivi2001 3a83e849cf feat(lanes): add a read-only feature picker to the Workspace page (B) 2026-08-04 15:00:41 +07:00
nntrivi2001 5b2f98ab4d feat(lanes): add ccam feature list/activate/show CLI (B) 2026-08-04 14:43:48 +07:00
nntrivi2001 5bc4d03740 feat(lanes): expose GET/POST /api/lanes/:id/features over lane-features.js (B) 2026-08-04 14:28:25 +07:00
nntrivi2001 aa01d77ce6 feat(lanes): clearLane archives the active feature before resetting (B) 2026-08-04 14:23:01 +07:00
nntrivi2001 181e0f77ff feat(lanes): add per-feature state + archive core (lane_features) (B) 2026-08-04 14:16:48 +07:00
nntrivi2001 465dca35e5 fix(docs): correct fabricated API/behavior claims in the locks docs
docs/API.md's Locks section described a timeoutMs request param, a
408-timeout response, and field names (acquiredAt/acquiredMs) that
don't exist anywhere in the actual routes.js — the server is single-shot
and never blocks; timing out is a CLI-only concept. Also fixes the
section being spliced into the middle of the pre-existing Sessions
heading and its content.

docs/LANES.md said the owner file stores an epoch in milliseconds
(it's seconds), that the CLI polls every ~1s (it's ~2s), that the lane
card shows a "waiting" state (no such server-side concept exists, only
who currently holds), and included a fabricated "manually transfer a
lock's holder identity" procedure that also contradicted the
never-touch-the-lock-directory etiquette rule stated right above it.
2026-08-04 14:02:40 +07:00
nntrivi2001 6c7554dbdc docs(locks): document cross-lane named locks (D) 2026-08-04 11:41:54 +07:00
nntrivi2001 c068f0cac6 style(i18n): restore literal UTF-8 chars in lanes.json locale files
The lock-indicator commit's JSON edit re-serialized both files through
something that escaped every non-ASCII character (—, …, ·, ▶, ■) as
\uXXXX, touching ~180 unrelated lines for a two-key addition. No
functional change (JSON \u escapes are semantically identical) — just
restoring the literal-character convention every other locale file uses.
2026-08-04 11:37:18 +07:00
nntrivi2001 1f3a5ff51d feat(locks): show a lock indicator on the lane card (D) 2026-08-04 11:32:34 +07:00
nntrivi2001 4a8725f136 feat(locks): add ccam lock status/acquire/release CLI (D) 2026-08-04 11:08:43 +07:00
nntrivi2001 7488a375e3 feat(locks): expose GET/POST /api/locks over named-lock.js (D) 2026-08-04 10:58:58 +07:00
nntrivi2001 2312c53788 feat(locks): add cross-lane named locks (mkdir-atomic, staleness floor) (D) 2026-08-04 10:53:58 +07:00
nntrivi2001 e783a2a946 docs(lanes): clarify --no-build is up-only; add A3 spec+plan docs
Help text for "lanes up|down" implied --no-build applied to both;
it's only read by up. Also commits the A3 profile-scaffolding design
spec and implementation plan that weren't yet in git.
2026-08-04 10:06:01 +07:00
nntrivi2001 4d9a385c5e feat(lanes): lane runtime UI, docs, and env additions (A1+A2)
Client-side rendering for the per-lane runtime facts (slot, ports,
database, Redis index, service liveness) added in the server-side
A1/A2 work, plus the doc updates (README, CLAUDE.md, docs/API.md,
client/server READMEs) describing the new profile.env keys, hook
environment contract, and REST endpoints.
2026-08-04 10:04:58 +07:00
nntrivi2001 9d145865dd feat(lanes): per-lane database, Redis, and .env isolation (A1+A2)
Gives each lane its own slot-derived runtime (ports, detached process
lifecycle, profile-driven hooks) and its own database/Redis logical
index/.env file, so two lanes running the same repo's stack at once no
longer share state. Machine-level DB/Redis credentials live at
~/.ccam/secrets.env (mode 0600, never returned by any route); a hook's
output is redacted of that password (raw and URL-encoded forms) before
it reaches a log file or the lane_hook_output websocket broadcast.
Wired into provision/up/reset/remove; reset accepts --keep-db to skip
the drop/recreate/migrate/reseed block entirely.
2026-08-04 10:03:40 +07:00
nntrivi2001 d71086f677 docs(lanes): document ccam lanes profile init/check (A3) 2026-08-04 09:52:58 +07:00
nntrivi2001 113ed01504 test(lanes): prove profile scaffolding end to end with a real boot (A3) 2026-08-04 09:48:01 +07:00
nntrivi2001 c024534475 feat(lanes): add ccam lanes profile init/check CLI (A3) 2026-08-04 09:42:25 +07:00
nntrivi2001 c3546f568a fix(lanes): add error handling for unreadable hook files in checkProfile 2026-08-04 09:36:58 +07:00
nntrivi2001 fa489016b4 feat(lanes): validate a scaffolded profile with checkProfile (A3) 2026-08-04 09:28:59 +07:00
nntrivi2001 9f9879cc3d feat(lanes): scaffold .ccam/profile/ from detected Node facts (A3) 2026-08-04 09:20:22 +07:00
nntrivi2001 c4b11799dc feat(lanes): detect docker-compose database/Redis and wire ENV_REWRITE (A3) 2026-08-04 09:13:20 +07:00
nntrivi2001 0ecc2eec24 feat(lanes): detect a Node.js project's layout and boot scripts (A3) 2026-08-04 09:06:45 +07:00
nntrivi2001 2c313084a8 fix(test): stop FORCE_COLOR/CCAM_COLOR leaking into spawned ccam CLI children
The sandbox's ambient FORCE_COLOR=3 was inherited by every ccam-cli.test.js
child process spawn, defeating the CLI's own "colors off when piped" default
and breaking every plain-text output assertion (help text, piped output,
offline-mode rendering).
2026-08-04 09:04:59 +07:00
482 changed files with 37484 additions and 36467 deletions
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-code-agent-monitor-plugins",
"description": "Official plugin marketplace for Claude Code Agent Monitor — 10 plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
"description": "Official plugin marketplace for Claude Code Agent Monitor — the `ccam` plugin installs the dashboard itself (hooks, server, CLI, MCP), plus 10 focused plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
"owner": {
"name": "smartgift",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
@@ -8,6 +8,12 @@
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"plugins": [
{
"name": "ccam",
"source": "./",
"description": "The dashboard itself. Installs the Claude Code event hooks, boots the local server, puts the `ccam` CLI on PATH and connects the MCP tools — no checkout and no `npm run setup`. Install this one first; the ten focused plugins below all read from the API it provides.",
"tags": ["dashboard", "monitoring", "hooks", "mcp", "cli"]
},
{
"name": "ccam-analytics",
"path": "plugins/ccam-analytics",
+119
View File
@@ -0,0 +1,119 @@
{
"name": "ccam",
"description": "Claude Code Agent Monitor — the full local-first dashboard as a plugin. Installs the event hooks, boots the dashboard server, puts the `ccam` CLI on PATH, and connects the MCP tools. No checkout, no npm run setup.",
"author": {
"name": "Nguyễn Ngọc Trí Vĩ",
"url": "https://git.smartgift.io.vn/Smartgift-AI"
},
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git",
"license": "MIT",
"keywords": ["dashboard", "monitoring", "sessions", "cost", "lanes", "mcp", "claude-code"],
"commands": [
"./plugins/ccam/commands/ccam-doctor.md",
"./plugins/ccam/commands/ccam-update.md",
"./plugins/ccam/commands/ccam-open.md"
],
"mcpServers": "./plugins/ccam/.mcp.json",
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
"timeout": 5
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" Stop",
"timeout": 5
}
]
}
],
"SubagentStop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SubagentStop",
"timeout": 5
}
]
}
],
"Notification": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" Notification",
"timeout": 5
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js\"",
"timeout": 10,
"statusMessage": "Checking the CCAM dashboard..."
},
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SessionStart",
"timeout": 5
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SessionEnd",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" UserPromptSubmit",
"timeout": 5
}
]
}
]
}
}
+176
View File
@@ -0,0 +1,176 @@
---
name: ship-feature-lane
description: "Autonomous end-to-end feature pipeline for ONE CCAM lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane <requirement>`. Frontloads ALL clarifying questions once, then runs unattended: implement (TDD) → pre-push CI gates + dev preflight → e2e on the feature branch → code review → local QC → senior GO/NO-GO gate → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass and the senior gate says GO — it's finalized when reviewers see it. Declares lane stage at every step via `ccam stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a CCAM lane. NOTE: ticket-filing and post-merge dev-site QC are currently OFF (F's integrations aren't built yet) — this pipeline stops at Stage 14 once dev CI/dev-QC support lands."
---
# Ship Feature Lane (CCAM lane pipeline)
You are running the autonomous feature pipeline for **one CCAM lane**. The human's only interactive touchpoints are **Stage 0 (frontloaded Q&A)** and **merging the PR on GitHub**; everything else runs to completion or to a `blocked` escalation, reporting progress through `ccam stage` (which the dashboard renders).
## Setup — do this first, every run
```bash
LANE_DIR="$(pwd)" # the lane clone IS your cwd — CCAM resolves the lane from this, never a hardcoded path
```
- CCAM resolves your lane from `cwd` automatically (longest path-boundary prefix match) — there is no marker file to check and no separate assign step. If `ccam stage` or `ccam feature activate` ever fails with "no lane found", you are not inside a lane's working directory; stop and tell the human.
- **Pipeline template is this skill's contract, not whatever the lane started on.** Before your first `ccam stage` call, run `ccam lanes pipeline ship-feature` (no id needed — resolves from `cwd` like `ccam stage`; idempotent, a no-op if already set) so every stage below resolves against the 16-node `ship-feature` map. The human is never expected to pick this in Add Lane or anywhere else — you force it to match the skill actually running.
- All stage updates go through `ccam stage <stage> [--status <s>] [--evidence "..."]`**call it at the start of every stage** (this is also the heartbeat, visible on the dashboard).
- **Integration toggles: check, don't assume.** `ccam lanes integration tracker`, `ccam lanes integration dev_qc`, `ccam lanes integration ci_wait` each exit 0 (on) or 1 (off), reading the profile's `integrations.env`. No agent exists yet to actually FILE a ticket or run dev-QC even when a toggle reads on (`ticketer`/`dev-qc` are a later task) — so regardless of the check's result, Stage 9 (ticket) stays skipped, Stage 13's dev-QC and dev-CI-wait halves stay skipped, and Stage 10's CI watch keeps using the plain `gh pr checks` path (`ccam ci` doesn't exist yet). Check the toggle where noted below anyway, so the evidence you record is honest about whether the PROFILE wants the integration on, distinct from whether CCAM can act on it yet.
- **Heartbeat during long stages.** Implementing (Stage 1), CI waits (Stage 10), and the watch/post-merge polls (Stages 1213) can run many minutes between stage transitions — bump the heartbeat with `ccam stage <same-stage>` after each commit and on each poll iteration, so the dashboard doesn't false-flag a working lane as stalled.
- Profile hooks (`bootstrap`/`boot`/`migrate`/`seed`/`ci-gate`/`e2e`/`health`/`regen`) run through `ccam lanes hook <name> [args…]` and `ccam lanes up`/`down`. Use them; don't reinvent their logic.
- **NEVER merge or rebase branches manually.** The ONLY merge that ever happens in this flow is `origin/development` INTO the feature branch, and only through `ccam lanes sync-base` (fetches fresh, pre-checks migration collisions, auto-regenerates generated files — see the note on this command in Stage 2; it is a LATER task, referenced here by its intended contract). There is no other direction: never merge a feature branch into anything locally, never commit on `development`, and never touch `main`.
- **NEVER push `origin/development` or `origin/main` (HARD).** `development` moves ONLY when a human merges a PR on GitHub. The only branch you ever push is your own `feat/<slug>` — and only after the senior gate's GO (Stage 8). If you ever find yourself typing `git push` with `development` or `main` on the line: STOP, `ccam stage <current> --status blocked`.
## Context recovery — after conversation compaction
Long pipelines outlive the context window. When context is compacted (summarized), re-derive these before continuing:
```bash
LANE_DIR="$(pwd)" # lane = cwd, resolved fresh
```
Then check your current position:
- **Lane state**: `ccam feature show <slug>` (or `ccam lanes` for the lane's own row) — shows current stage, status, feature title, branch, gate decision, PR URL, notes.
- **Git branch**: `git rev-parse --abbrev-ref HEAD` — which branch you're on.
- **Feature slug**: from the branch name (`feat/X``X`), or from `ccam feature list` (the lane's currently-active feature is marked `▶`).
Resume from the stage shown. If state says `stage=X status=running`, you were mid-stage X when context compacted — re-run that stage from the top (all `ccam` commands are idempotent).
- **MCP preflight (fail fast):** if this is the lane's first run, or a required Playwright MCP's `browser_*` tools aren't available in this session, run `ccam lanes mcp sync` — it relocates the source repo's already-configured MCP servers into this lane's `.mcp.json`. If the source repo has none configured (`ENOMCPCONFIG`), tell the human to configure `.mcp.json` manually for the source repo first. Either way, restart the session after syncing so the new config loads — `playwright` and a local-QC MCP are always required for Stage 3/6. Catching this at Stage 0 costs a minute; catching it at Stage 13 strands a merged feature unverified.
- **Agents preflight:** if this is the lane's first run through this pipeline, run `ccam lanes agents install` once — it writes the `qc-local` and `senior-gate-reviewer` agent definitions into the lane's own `.claude/agents/` (idempotent; re-running just overwrites with the current templates). Stage 6/7 launch these by `subagent_type`; skipping this leaves those stages unable to find the agent.
## Hard rules
- **Publish ONLY after the senior-gate-reviewer returns `VERDICT: GO`.** "Publish" = push the feature branch + open/update the PR (Stage 8). Nothing reviewer-visible exists before GO, and nothing else authorizes it.
- **The fix-loop:** any failure in stages 27 (gates/preflight, e2e, review, QC, senior gate), any red PR CI that's genuinely yours (Stage 10), and any worth-fixing review comment (Stage 12) → fix on the **feature branch**, and re-run **from Stage 2 through Stage 8** (gates+preflight → e2e → review → QC plan → QC → senior gate → publish/update PR), then the Stage 10 CI watch. Never skip a gate — the full process applies; no shortcuts because "it's just review feedback".
- **EXCEPTION — test-only re-entry (browser-QC fast-path).** If the re-entry's change is ENTIRELY test files — `git diff --name-only` since the last browser-QC'd commit matches only test paths — the app's runtime behavior/UI is unchanged from the last QC'd pass. Run this EXACT stage set, nothing else:
- **Always run:** Stage 2 (gates + preflight), Stage 7 (senior gate), Stage 8 (publish/update PR), and the Stage 10 tail (CI watch).
- **Run only if e2e spec files are among the changed tests:** Stage 3 (boot + e2e on the feature branch). No e2e specs changed → skip it.
- **Always SKIP** (runtime UI unchanged): Stage 5 (QC plan), Stage 6 (qc-local). Record `--evidence "QC skipped: test-only change"`.
- If the diff contains ANY non-test file → this fast-path does NOT apply; take the full path above. The first pass (not a re-entry) always runs full QC.
- **EXCEPTION — localized re-entry (scoped-e2e fast-path).** On a re-entry whose diff since the last fully-validated commit is SMALL and LOCALIZED — only files inside the feature's own surface, NO migrations, NO contract/generated files, NO shared fixtures/utilities, NO dependency changes — you may shrink Stage 3's e2e to a SCOPED run of the specs covering the touched surface: `ccam lanes hook e2e -- <spec files>` (scoped runs still heartbeat + lock + time-bound like the full suite), and browser QC runs SCOPED to the affected QC-Plan scenarios (tell the qc agent exactly which scenario numbers). Know the trade-off: there is no dev-merged full suite anymore — post-merge dev CI + dev-QC (Stage 13, currently off) are normally the integration net; without them, a localized re-entry after this lands is a real gap until F ships. If in doubt whether the change is localized, it isn't — run the full path.
- **Run long helpers so they can't be killed mid-flight or hang your turn.** `ccam lanes hook ci-gate`, `ccam lanes up`, `ccam lanes hook e2e` legitimately run 320+ minutes (builds, tests, Playwright, lock waits). NEVER invoke them with the default Bash timeout (2 min kills them mid-flight and strands the lane half-done): use `run_in_background: true` and poll the output file until done, or set `timeout: 600000` for the shorter gates. If a helper does die mid-run, don't panic: every hook is idempotent — re-run the step (e.g. re-run `ccam lanes up --no-build` to revive a stack).
- **Waiting + polling NEVER use a foreground `sleep`** (the Bash tool blocks it) or `ScheduleWakeup` (that's a `/loop`-only primitive — this pipeline is not a `/loop` session, so it won't sustain your watch). To pace a poll loop or wait out a timer, **background the wait**: run `sleep <secs>` with `run_in_background: true` — you're re-invoked when it exits, and re-invoked the moment a backgrounded Agent or helper finishes, so you never busy-poll for background work. **If you ever can't sustain a wait/loop in this session, set `ccam stage <current> --note "<honest note>"` and STOP — never narrate a watch or loop you are not actually running.**
- **e2e: actively poll — never wait on the completion re-invoke alone.** A hung suite never fires it, stranding the lane at `stage=e2e-feature`. When running `ccam lanes hook e2e` (Stage 3): start it `run_in_background: true` AND background a `sleep 90` beside it. Each wake — finished → parse PASS/FAIL; still running → read the e2e log tail (`ccam lanes logs <id> e2e`), bump the heartbeat, and re-background `sleep 90`, UNLESS it's erroring or has run past ~22 min, in which case kill the e2e task and treat it as FAIL → re-enter Stage 2.
- **Turn-liveness: never let the pipeline die silently.** A "stalled" lane usually died one of two ways: (a) a turn ended with NOTHING pending — no backgrounded wait, no running helper, no background agent — so nothing ever re-invoked the session; or (b) a transient API error (rate-limit, 529/overload, connection refused) killed the turn mid-stage. Rules: while the pipeline is anywhere between Stage 1 and Stage 14 (done), every turn you end MUST leave at least one re-invoker pending (a `run_in_background` helper/sleep or a background agent) — check before ending the turn. And on ANY resume after an error or a human nudge ("continue"), do not ask questions: re-derive position from lane state (Context recovery above) and continue the stage. If you truly cannot leave a re-invoker, set `--note "watch needs re-trigger: <what to do>"` so the dashboard shows it honestly.
- **No retry cap — the phase clock is the signal.** A failing gate/QC/CI/e2e just re-enters the loop (fix on the feature branch, re-run from Stage 2); there is NO automatic block after N attempts. The dashboard shows how long the lane has sat in its current stage, so the human can spot a stuck or endlessly-looping lane and step in. Reserve `--status blocked` for GENUINE blockers you cannot resolve (an ambiguous merge conflict, a hard/unrecoverable error).
- **Commit only on the feature branch.** Never commit on `development`/`main`. Stage only intended files (never `git add .` blindly — this repo collects stray build/QA artifacts).
- Keep the lane's state truthful: on any stop, set an accurate `ccam stage <stage> --status <status> --note "<why>"`.
- **Quality bar (applies to every code change, including fix-loop re-entries and follow-up PRs).** Tests are sharp and meaningful — each pins a real behavior/edge case (happy + negative + boundary), none trivial, redundant, or coverage-padding. Comments are minimal — only the non-obvious *why*, matching the surrounding density; never narrate the *what*. Investigate before fixing (root cause, not symptom — use **systematic-debugging**). Prefer reusing/extending existing code over duplicating it.
- **One driver per MCP browser server.** Each MCP server owns ONE browser; two agents driving the SAME server interleave clicks in one tab. The local-QC MCP → the qc-local agent (Stage 6) only; the general `playwright` MCP → the main session for ad-hoc checks only (never while qc-local runs). Parallel agents on DIFFERENT servers are safe by design; a second concurrent driver on the SAME server is never OK.
- **Cross-lane etiquette (locks + siblings).** Lanes share one machine and one dev site. Waiting on a cross-lane serializer (`ccam lock acquire <name>`, e.g. around a shared build/e2e step) is NORMAL — it heartbeats while it waits, so you won't look stalled. NEVER free a lock by killing another lane's session or processes, deleting the lock's directory by hand, or shrinking `LOCK_MAX_HOLD`; a dead holder's lock auto-expires on its own. If a lock wait times out: re-try with a longer `--timeout`, or set `--status blocked` with a note and report. Touch ONLY your own lane's clone, state, and locks you hold.
## Stages
### 0 — Intake & frontloaded Q&A *(the only interactive part)*
Do NOT jump to code. Understand the requirement first.
- **Restate + quick scan.** Restate the requirement. Do a fast targeted scan of the relevant code (use the `Explore` agent for breadth; the **brainstorming** skill if the requirement is fuzzy) so your questions are grounded in what actually exists.
- **Frontloaded Q&A.** Ask the human **every** clarifying question in ONE batch: acceptance criteria, scope / non-goals, UI/UX specifics, data shapes, edge cases, which existing flows it touches. **Sibling-surface check (mandatory):** if your scan shows the app has N parallel surfaces of the pattern the requirement touches (e.g. several foldered areas, several list pages sharing a component) and the requirement names fewer than N, explicitly ask "this exists in [all N places] — apply to all, or only [the named ones]?" A missed sibling here costs a full second pipeline pass when a reviewer catches it on the PR. Then activate a clean feature slot for this run: `ccam feature activate <slug> --title "<short title>"` — Task B's `activate` archives whatever feature was previously active on this lane automatically, so this run's dashboard state starts clean without a separate "init" step. Then mark intake: `ccam stage intake --status running`. Announce "Questions answered — going autonomous now." After this, don't ask the human anything unless you hit a `blocked` escalation.
### 0b — Investigate & plan *(autonomous)*
- `ccam stage plan` — now design a real plan and have it independently challenged before you implement.
- **Investigate (autonomous, thorough).** Read the actual code paths, models, existing tests, and conventions the feature touches — `Explore`/`general-purpose` subagents for breadth, then read the key files yourself for depth. Pin down: integration points, data/migration needs, API/contract impact, reuse opportunities, and risks. Use **systematic-debugging** if the feature is a fix (root-cause first, no symptom patches).
- **Plan.** Produce a concrete implementation plan (the **writing-plans** skill): approach, files to change, the test strategy (which behaviors/edge cases each test will pin), migration/contract impact, and how each acceptance criterion is met.
- **Debate the plan (adversarial review).** Spawn a SEPARATE sub-agent (Agent tool — `Plan` or `general-purpose`) to critique the plan + investigation: missed requirements, wrong assumptions, a simpler approach, unhandled edge cases, acceptance-criteria gaps. Apply the worthwhile critiques (use **receiving-code-review** judgment — verify each point, don't blindly accept or reject). Iterate once or twice until the plan holds up.
- Write the Q&A answers **and the agreed plan** to a lane spec file `docs/superpowers/specs/lane-<slug>.md` (gitignored, or add it to `.gitignore` if this is the first one) — the acceptance contract the senior gate checks against.
### 1 — Implement (TDD, to the plan)
- Choose a **single-segment slug** for the feature — lowercase, hyphens, NO slashes. Cut the feature branch from **development** (the PR base): `git fetch origin && git checkout -b feat/<slug> origin/development`.
- If Stage 0 activated a placeholder slug different from the final chosen one, reconcile: `ccam feature activate <slug>` — it echoes back the canonicalized slug it actually stored; use THAT for the branch and every later reference.
- Implement the agreed plan with the **test-driven-development** skill: failing test → minimal code → green → commit. Frequent small commits.
- **Tests must be sharp and meaningful.** Each test pins a real behavior or edge case from the plan / acceptance criteria — cover the happy path, the negative/error path, and boundaries. NO trivial or redundant tests: don't assert constants or framework internals, don't re-test the same path twice, don't pad for coverage. A few precise tests that would actually catch a regression beat many shallow ones.
- **Comment only when it earns its place.** Match the surrounding code's comment density. Comment the non-obvious *why* (intent, invariants, gotchas, links to context) — never narrate the *what* the code already says. Delete redundant/boilerplate/restating comments rather than adding them.
- If your stack generates an API contract/client and the API changed, regenerate it (`ccam lanes hook regen`) so the contract-check gate passes (stacks without a contract gate skip this).
- `ccam stage implementing`
### 2 — Pre-push CI gates + dev preflight (on the feature branch)
- `ccam stage gates --status running` — declare it FIRST. This is also the fix-loop's re-entry point, and the declaration is what moves the lane BACK down the pipeline: detection alone can't (`recordDetection` is forward-only and never overrides a higher declared stage), so a re-entry that skips this line leaves the dashboard showing the stage you already left.
- `ccam lanes hook ci-gate` — runs the profile's CI gate (lint / test / contract checks) against an isolated per-lane test DB. On failure: read the output, fix on the feature branch, commit, re-run. Loop until green.
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: declare `ccam stage migration-collision --status running` (an alias of `gates` — the map stays put, the lane's stage names WHY it is sitting there), rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally.
### 3 — E2E on the feature branch
The e2e hook doesn't run migrations itself — it tests the already-running stack. To exercise the feature's code and any new schema, boot the lane stack with the feature branch first:
- `ccam stage booting --status running` — an alias of `e2e-feature`; the boot below can take minutes and this says which minutes they are.
- `ccam lanes up --qc` — boots with the profile's QC env (mock/stub flags so QC is deterministic, from `QC_BOOT_ENV`), applies the feature branch's own migrations, and reboots the stack. Idempotent; safe to re-run. (The branch was cut from `origin/development`, so this stack IS development + your feature as of the branch point.)
- `ccam stage e2e-feature --status running`, then `ccam lanes hook e2e` — Playwright e2e under the e2e lock against the now-booted stack. **This is the only e2e gate in the flow** — there is no dev-merged suite behind it.
- On failure: fix on the feature branch, commit, re-run from Stage 2.
- **Iterating on a failing spec:** declare `ccam stage e2e-scoped` (alias of `e2e-feature`) so the dashboard shows this is a narrowed run, not the gate, and use scoped runs through the hook — `ccam lanes hook e2e -- <spec file/filter>` — never a bare test-runner invocation in the lane (bare runs skip the cross-lane lock, the hard timeout, and the heartbeat, so the dashboard false-flags STALLED). A scoped green is never the gate; finish with the full suite (unless the localized fast-path applies — see Hard rules).
- On success: `ccam stage e2e-feature-passed --status running`
### 4 — Code review *(no open PR yet — use local diff)*
- Run the **`code-review` skill at effort `high`** on the feature diff vs `origin/development` — this is the deterministic code-review gate, not an ad-hoc read. The PR isn't open yet, so point it at the local diff: `git diff origin/development...feat/<slug>` (and `git log origin/development..feat/<slug>` for commits). ONLY if the `code-review` skill is unavailable, fall back to a manual review of that diff (correctness, security, tests, migration/contract safety). The Stage-6 `qc-local` report covers the user-flow review for the senior gate.
- Apply the fixes worth making on the feature branch; if you change code, re-run **from Stage 2**.
- `ccam stage review`
### 5 — QC plan *(bound the test scope before any browser QC)*
- Author a **QC Plan** the browser-QC agents (Stage 6 now, and a future Stage 13 once dev-QC exists) will execute against — so QC covers everything that matters and nothing that doesn't (no missed scenarios, no wandering into unrelated areas). Derive it from the acceptance points (lane spec) + the real change surface (`git diff origin/development...feat/<slug>` and `--stat`). Three parts:
- **In-scope scenarios** (numbered): each acceptance point with positive AND negative cases; adjacent flows sharing routes/components/data with the change; required **state coverage** (reload on each stateful screen touched, one logout→re-login, back/forth nav); and the required **UI/UX layout checks** for every form/screen the feature touches (narrow AND short viewport, expandables open so content exceeds the viewport, fixed chrome not clipped, every control labelled, section headers more prominent than field labels).
- **Out-of-scope** (explicit): areas NOT to test because the change cannot affect them — this is what stops QC from over-testing.
- **Smoke set**: login + main nav + ≥3 unaffected major areas.
- Append it to the lane spec under a `## QC Plan` heading (`docs/superpowers/specs/lane-<slug>.md`) — the same file the senior gate reads. You are the **single writer** of this section; the QC agent only *proposes* additions in its report and you fold them in (Stage 6). This keeps the plan race-free yet living.
- `ccam stage qc-plan --status running`
### 6 — Browser QC via the qc-local agent
- **Test-only fast-path:** on a fix-loop re-entry whose change is ENTIRELY test files (see the fix-loop rule), SKIP this stage — the app's runtime UI is unchanged — and record `--evidence "QC skipped: test-only change"`. Otherwise run it:
- `ccam stage qc --status running`, then launch the **qc-local** agent (Agent tool, `subagent_type: qc-local` — FOREGROUND; it gates the pipeline; the Setup section's agents preflight already installed it). Give it: the lane's working directory, the feature slug, the feature title, the acceptance points (lane spec), and the **QC Plan** (lane spec, Stage 5) as the authoritative scope to execute against. It owns the whole local browser QC and proof capture (`ccam lanes proof-link` first, then screenshots land under the proof gallery automatically). It runs against the lane's feature-branch stack from Stage 3. Do NOT drive the browser yourself at this stage.
- Parse its last line: `LOCAL-QC: PASS` → continue. `LOCAL-QC: FAIL — <reasons>` → fix on the feature branch → re-run from Stage 2. Keep its report — it is the feature user-flow review for the senior gate.
- **Fold back discoveries:** if its report lists scenarios it found that weren't in the plan (its "Scenarios discovered during QC" section), add them to the `## QC Plan` in-scope list in the lane spec.
### 7 — Senior GO/NO-GO gate *(authorizes the publish)*
- Launch the **senior-gate-reviewer** agent (Agent tool, `subagent_type: senior-gate-reviewer` — installed the same way as `qc-local`, see Stage 6). Give it: the lane's working directory, the requirement + Stage-0 answers (the lane spec file), the feature branch, the Stage-4 code-review findings + resolutions, the Stage-6 `qc-local` report (the user-flow review), and confirmation that gates/e2e/review/QC passed. The agent inspects the local diff with `git diff origin/development...feat/<slug>` — no open PR is required (and none exists yet).
- Parse its final line:
- `VERDICT: GO` → proceed to Stage 8.
- `VERDICT: NO-GO — <fixes>``ccam stage gate-blocked --evidence "NO-GO — <reason>"` (an alias of `gate`, so the map holds while the lane's stage name says the gate refused), then fix on the feature branch and re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve.
- `ccam stage gate --evidence "GO"` (or `NO-GO — <reason>`)
### 8 — Publish: push branch + open/update PR *(GATED — only on GO)*
- `ccam stage publishing --status running`
- `ccam stage push-revalidate` (alias of `pr-open`) — then re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push.
- `git push -u origin feat/<slug>` — first push of the feature branch to remote. All gates have passed before this point; the PR is finalized before reviewers see it.
- Open or update the PR **based on and targeting `development`**: `gh pr create --base development --fill` (or `gh pr edit` / the push itself if a prior run already created it). Capture the URL.
- `ccam stage pr-open --evidence "<pr-url>"` — the dashboard shows the PR link from here (via `--evidence` in `ccam feature show`/`ccam lanes`).
### 9 — Ticket *(currently SKIPPED — tracker integration is off)*
- Tracker integration is hardcoded off (see Setup). Do nothing here; do not attempt to file a ticket. When F ships `ccam lanes integration tracker`, this stage gets a real implementation. (Check `ccam lanes integration tracker` in the heartbeat: even when on, no ticketer agent exists yet to act on it.)
### 10 — CI watch on the PR *(non-blocking)*
- Check the PR's CI with `gh pr checks` / `gh`. Green → continue to the report + watch — never idle waiting for green.
- **Red CI → triage before the fix-loop** (shared CI flakes under multi-lane load; treating every red as your defect wastes cycles):
1. Read WHICH job/tests failed with `gh` (there is no `ccam ci` yet — F builds that; use `gh run view`/`gh pr checks --watch` directly).
2. **Your tests / your code implicated** → real failure: fix on the feature branch, re-enter from Stage 2.
3. **Infra/flake signature** (a job with no test failures, OOM/contention on the shared runner, a hung job with no output, or a test that is green locally on the identical tree) → re-run the workflow via `gh run rerun <run-id> --failed` — ONCE. Still red after the rerun → treat it as real (or escalate with the evidence). Never rerun more than twice, and never push an empty commit to re-trigger CI.
4. A **hung** workflow (running way past its normal duration with no output) → `gh run cancel <run-id>` then rerun once.
### 11 — Report
- Post a concise report: PR URL, CI status, what shipped, and that the PR now **awaits a human merge** (this pipeline never merges).
- `ccam stage reported --status running`
- Do **NOT** clear or reset the lane's feature state. Cleanup is the human's call, from the dashboard — they may still be manually testing.
### 12 — Watch the PR *(until a human merges or closes it)*
- `ccam stage watching-pr --note "watching PR for comments + base conflicts + the merge"`
- Loop every ~5 minutes, paced by a **backgrounded** wait so the turn isn't pinned (see the waiting-primitive rule above): run `sleep 300` with `run_in_background: true` — you're re-invoked when it elapses. Each iteration:
- Check PR state: `gh pr view <pr_url> --json state,mergeable -q '.state + " " + (.mergeable|tostring)'`, and bump the heartbeat (`ccam stage watching-pr`).
- `MERGED` → a human merged it: go to **Stage 13** (post-merge verification).
- `CLOSED` (unmerged) → the human rejected/abandoned it: `ccam stage done --status passed --note "PR closed unmerged by human"` → STOP.
- `CONFLICTING` → the feature branch conflicts with `development`. **Base guard first:** check the PR's actual base — `gh pr view <pr_url> --json baseRefName -q .baseRefName`. If it is NOT `development` (the base drifted — a human retargeted the PR, or it predates this pipeline), do NOT auto-merge anything: `--status blocked --note "PR base is not development — human decision"` and STOP. If the base IS `development`, declare `ccam stage sync-conflict --status running` (an alias of `gates`, which is also where the re-entry below lands — one declaration, honest about both) and resolve it as real work:
- `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). A migration-number collision (exit 5) means nothing was merged — rename the printed file on the feature branch, re-run Stage 2, then retry this step.
- **Exit 4 — merge conflict, left in place on purpose.** Resolve every conflict thoughtfully on the feature branch — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. Never hand-merge a generated contract/client file listed in the profile's `GENERATED_MERGE_PATHS` — the keep-ours driver + regen own them. `git add` ONLY the conflicted files, `git commit --no-edit`, then `ccam lanes sync-base --continue feat/<slug>` (folds any regenerated artifacts into a follow-up commit).
- Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching.
- For each new PR comment (list with `gh pr view <pr_url> --json comments`, tracking which you've already handled by comment id in your own notes), triage AND **always reply on its thread** (every comment gets a response — no silent handling, so reviewers see it was considered):
- **Worth fixing** (reviewer-requested change, real bug, test/doc gap): this is a NEW CHANGE — `ccam stage pr-comment-fix --status running` (alias of `watching-pr`, so the lane reads as *acting on a comment* rather than idly polling), then apply it on the feature branch and re-enter the pipeline **from Stage 2 through Stage 8** (+ Stage 10 CI watch). The full process applies; no shortcuts because "it's just review feedback". **After the fix is pushed, reply to the comment** confirming resolution — what changed + the commit/PR ref — by writing the reply to a file and posting `gh pr comment <pr_url> --body-file <path>` referencing the comment. **Never inline `--body "..."`** — bodies carry backticks/`file:line`/`$(...)` that bash reads as command substitution inside double quotes, which corrupts the comment and trips an approval prompt. Then come back here and keep watching.
- **Question / discussion**: answer it via `gh pr comment <pr_url> --body-file <path>` (same file-not-inline rule) — no code change.
- **Not worth fixing** (out of scope, working as intended, deferred): **reply with the reasoning** so the reviewer knows why it wasn't actioned (don't just skip it).
- **Sign every reply** with a distinct attribution — end each posted body, on its own line, with: `— 🤖 ship-feature-lane pipeline`.
- Nothing new → background another `sleep 300` (`run_in_background: true`) and end the turn; you'll be re-invoked for the next poll. A PR can sit for days — that's fine.
### 13 — Post-merge verification *(currently SKIPPED — CI-wait and dev-QC integrations are off)*
- `ccam stage merged --status running --note "PR merged — post-merge verification unavailable (F not built)"`
- Both dev-CI-wait and dev-QC are hardcoded off (see Setup). Go straight to Stage 14. When F ships these integrations, this stage gets its real implementation (mirroring Shipyard's: dev CI watch, then a `dev-qc` agent QCing the deployed site, looping to a Stage 15 follow-up-fix pattern on any issue found).
### 14 — Done
- Post the final report: PR merged, that post-merge verification is unavailable pending F.
- `ccam stage done --status passed --note "merged; post-merge verification unavailable (F not built)"` → STOP (leave the lane for the human to clear from the dashboard whenever).
## Escalation
Whenever you STOP early (an ambiguous merge conflict, an unexpected/unrecoverable failure, a missing agent this skill depends on), set `ccam stage <current> --status blocked --note "<what the human must decide>"` — the dashboard surfaces it. Then summarize for the human and wait.
+94 -16
View File
@@ -1,28 +1,106 @@
---
name: ship-feature
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates.
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. Drives the Superpowers workflow skills and declares each phase with `ccam stage` so the dashboard's pipeline map follows along.
---
# Ship Feature
Use this workflow for medium or large implementation tasks.
Use this workflow for medium or large implementation tasks. It does not restate
how to plan, test, or review — the Superpowers skills own that. What lives here
is the phase order, this repository's own rules, and the stage declaration at
each boundary.
## Steps
- Explore impacted modules first.
- Write a short implementation plan before editing.
- Implement smallest coherent diff that satisfies requirements.
- Run relevant verification commands.
- Update docs when commands, paths, architecture, or behavior changed.
For a feature inside a CCAM **lane**, use `ship-feature-lane` instead: it adds
the branch/e2e/QC/senior-gate/PR half this skill deliberately leaves out.
## Required quality checks
- Keep API and websocket contracts stable unless intentionally changed.
- Keep destructive operations behind explicit guardrails.
- Avoid broad refactors in feature tickets unless requested.
## Declaring the stage
## Finish checklist
- Tests/build/typecheck completed or explicitly reported as not run.
- Changed file set is scoped and intentional.
- User-facing docs updated if behavior changed.
Each phase below starts with `ccam stage <node>`, which is what puts the phase
on the lane's pipeline map. The nodes are the `default` template's:
`intake → plan → implement → tests → review → gate → ship → done`.
Set the template yourself, don't rely on how the lane was created: before your
first `ccam stage` call, run `ccam lanes pipeline default` (no id needed —
resolves from `cwd`; a no-op if the lane is already on it). This is what makes
the choice invisible to the human — whichever of `ship-feature` or
`ship-feature-lane` actually runs is what decides the template, not a picker
they have to get right in advance.
`ccam stage` needs a lane owning the current directory. If it reports no lane,
this repo was never adopted (`ccam lanes add --cwd $(pwd)` fixes it) — carry on
with the workflow and skip the stage calls; they are reporting, not control
flow. Never skip a phase because its stage call failed.
Declaring beats detection. The dashboard also *infers* these stages from the
Superpowers skill invocations below, but an inference never renders `done` and
never overrides a declaration — and detection cannot move a lane BACKWARD past
a declared stage, so a rework loop is invisible unless you re-declare the phase
you dropped back to.
## Phases
### intake — understand before touching anything
- `ccam stage intake --status running`
- Restate the requirement and its success criteria.
- Explore the impacted modules: `Explore` agent for breadth, then read the key
files yourself. Identify which layers are hit (server / client / mcp / docs /
scripts) — `repo-onboarding` if the area is unfamiliar.
- Requirement fuzzy or open to more than one reading? **superpowers:brainstorming**.
### plan — a written plan, challenged before it is code
- `ccam stage plan`
- **superpowers:writing-plans**: approach, files to change, the test strategy
(which behavior each test pins), and how each success criterion is met.
- If the task is a bug rather than a feature: **superpowers:systematic-debugging**
first. Root cause, not symptom — and grep every caller of the function you
are about to change, not just the path the report names.
### implement — smallest coherent diff
- `ccam stage implementing`
- **superpowers:test-driven-development**: failing test → minimal code → green.
- Smallest diff that satisfies the requirement. Every changed line traces to it.
- **file-headers** applies to every source file you create or edit.
### tests — this repo's verification, not a claim
- `ccam stage tests --status running`
- Backend changed → `npm run test:server`. Frontend → `npm run test:client`.
MCP → `npm run mcp:typecheck` + `npm run mcp:build`.
- A UI snapshot diff is reviewed, never blindly regenerated
(`cd client && npx vitest run -u` only after you have read the diff).
- Record the outcome: `ccam stage tests --evidence "<what passed>"`, or
`--result fail` with what failed. A step you could not run is reported as not
run, never as passed.
### review — a real review pass, not a re-read
- `ccam stage review`
- Run the **code-review** skill on the working diff, or
**superpowers:requesting-code-review** when handing it to an agent.
- Apply what is worth applying with **superpowers:receiving-code-review**
judgment: verify each point, neither blind agreement nor blind rejection.
- Changed code in response? Re-run the `tests` phase.
### gate — evidence before the completion claim
- **superpowers:verification-before-completion**. Commands actually run, output
actually read. This is the phase that stops "should work" from shipping.
- `ccam stage gate --evidence "<what was verified>"`
### ship — docs, then the user's call
- `ccam stage ship`
- **update-project-docs** — mandatory for any change to behavior, config,
interfaces, events, schema, CLI commands, or features. Not optional, not
deferred, not "if asked".
- Commit / push / PR **only when the user asks in this turn**. Finishing an
implementation is not authorization to commit.
- `ccam stage done --status passed --evidence "<what shipped>"`
## This repository's own rules
Not restated here. `CLAUDE.md` is loaded in every session and already binds
them — backward-compatible API/WebSocket contracts, fail-safe hooks,
migration-safe schema changes, the destructive-lane and git-argv guards, and
the lane boundaries (the console never writes a stage; the runtime never writes
`stage`/`status`/`notes`). A second copy here would only drift out of sync with
the first. Read `CLAUDE.md` and `.claude/rules/` for the area you are touching.
## References
- Checklist template: `references/feature-checklist.md`
@@ -52,10 +52,10 @@ i18n architecture: **Supported languages** list, `supportedLngs`, the 15 namespa
## Tier 3 — situational
- `.env.example` — every env var belongs here with a sane default + comment.
- `INSTALL.md`, `SETUP.md`, `DEPLOYMENT.md`, `docs/DEPLOYMENT.md` — install/run/deploy commands.
- `INSTALL.md`, `SETUP.md` — install/run commands.
- `CLAUDE.md`, `AGENTS.md` — agent working guides; update when commands, file locations, or workflows change.
- `docs/README.md` — docs index; add a link when a new `docs/*.md` is created.
- `desktop/README.md`, `vscode-extension/README.md`, `statusline/README.md` — surface-specific; update only when that surface changes.
- `vscode-extension/README.md`, `statusline/README.md` — surface-specific; update only when that surface changes.
## Consistency invariants
+5
View File
@@ -7,6 +7,11 @@
# Port to listen on (default: 4820)
# DASHBOARD_PORT=4820
# Directory the built client is served from in production (default: client/dist).
# The `ccam` plugin install points this at ~/.claude/agent-dashboard/runtime/client-dist
# because the plugin cache directory is read-only and replaced on every update.
# DASHBOARD_CLIENT_DIST=
# Interface to bind. SECURITY: defaults to 127.0.0.1 (loopback) so the dashboard
# is NOT reachable from the network out of the box (GHSA-gr74-4xfh-6jw9). The
# server reads transcripts, exports all data, and can spawn `claude`, so binding
+8 -1
View File
@@ -6,7 +6,10 @@ jspm_packages/
dist/
build/
client/dist/
mcp/build/
# mcp/build/ is deliberately COMMITTED: Claude Code starts a plugin's MCP
# servers the moment the session opens, so the artifact has to exist before any
# bootstrap could build it. Freshness is enforced by scripts/check-mcp-build.js.
!mcp/build/
desktop/out/
desktop/release/
desktop/assets/icon.iconset/
@@ -18,6 +21,10 @@ desktop/assets/icon.iconset/
*.db-wal
*.db-shm
# Local lane profile - only present when this repo itself is adopted as a
# lane (`ccam lanes add --cwd`); machine-specific runtime config, not source.
/.ccam/
# Environment variables
.env
.env.local
+10 -1
View File
@@ -36,7 +36,16 @@ else
echo "🎨 No staged files to format."
fi
# ── 2. Run tests — commit is blocked unless all pass ────────────────────────
# ── 2. Committed MCP build must match mcp/src ───────────────────────────────
# mcp/build/ is committed because a plugin's MCP server starts before any
# bootstrap could build it. Only enforced when mcp/src is part of this commit,
# so unrelated commits are not blocked by a stale artifact.
if git diff --cached --name-only --diff-filter=ACMR | grep -q '^mcp/src/'; then
echo "🔌 Checking the committed MCP build against mcp/src..."
node scripts/check-mcp-build.js
fi
# ── 3. Run tests — commit is blocked unless all pass ────────────────────────
# Each suite is retried once on failure. The full run executes dozens of test
# files concurrently, each starting its own server (plus the CLI suite's
# spawned child processes with hard kill timeouts), so a loaded machine can
+52 -396
View File
@@ -35,35 +35,13 @@ Architectural overview and technical reference for the Agent Dashboard system, c
![OpenAPI](https://img.shields.io/badge/OpenAPI-3.0-000000?style=flat-square&logo=openapiinitiative&logoColor=white)
![Swagger](https://img.shields.io/badge/Swagger-3.0-85EA2D?style=flat-square&logo=swagger&logoColor=white)
![VS Code](https://img.shields.io/badge/VS_Code-Extension-007ACC?style=flat-square&logo=vscodium&logoColor=white)
![Electron](https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron&logoColor=white)
![electron-builder](https://img.shields.io/badge/electron--builder-25.1-2c2e3b?style=flat-square&logo=electron&logoColor=white)
![macOS](https://img.shields.io/badge/macOS-Desktop_App-000000?style=flat-square&logo=apple&logoColor=white)
![Windows](https://img.shields.io/badge/Windows-Desktop_App-0078D6?style=flat-square&logo=windows&logoColor=white)
![SMAppService](https://img.shields.io/badge/SMAppService-Login_Items-000000?style=flat-square&logo=apple&logoColor=white)
![macOS DMG](https://img.shields.io/badge/macOS_DMG-arm64_%2B_x64-7c3aed?style=flat-square&logo=apple&logoColor=white)
![Vitest](https://img.shields.io/badge/Vitest-1.0-646CFF?style=flat-square&logo=vitest&logoColor=white)
![React Testing Library](https://img.shields.io/badge/React_Testing_Library-13.0-FF5733?style=flat-square&logo=testinglibrary&logoColor=white)
![ESLint](https://img.shields.io/badge/ESLint-8.44-4B32C3?style=flat-square&logo=eslint&logoColor=white)
![Prettier](https://img.shields.io/badge/Prettier-3.8-F7B93E?style=flat-square&logo=prettier&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-20.10-2496ED?style=flat-square&logo=docker&logoColor=white)
![Podman](https://img.shields.io/badge/Podman-4.0-CC342D?style=flat-square&logo=podman&logoColor=white)
![Terraform](https://img.shields.io/badge/Terraform-%3E%3D1.5-844FBA?style=flat-square&logo=terraform&logoColor=white)
![Kubernetes](https://img.shields.io/badge/Kubernetes-%3E%3D1.24-326CE5?style=flat-square&logo=kubernetes&logoColor=white)
![Helm](https://img.shields.io/badge/Helm-3-0F1689?style=flat-square&logo=helm&logoColor=white)
![Kustomize](https://img.shields.io/badge/Kustomize-5.0-326CE5?style=flat-square&logo=kubernetes&logoColor=white)
![Nginx](https://img.shields.io/badge/Nginx-Ingress-009639?style=flat-square&logo=nginx&logoColor=white)
![Prometheus](https://img.shields.io/badge/Prometheus-2.x-E6522C?style=flat-square&logo=prometheus&logoColor=white)
![Grafana](https://img.shields.io/badge/Grafana-10.x-F46800?style=flat-square&logo=grafana&logoColor=white)
![Coralogix](https://img.shields.io/badge/Coralogix-Observability-1a1a2e?style=flat-square&logo=datadog&logoColor=white)
![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-Collector-4f46e5?style=flat-square&logo=opentelemetry&logoColor=white)
![AWS](https://img.shields.io/badge/AWS-ECS%20%7C%20RDS-232F3E?style=flat-square&logo=task&logoColor=white)
![Google Cloud](https://img.shields.io/badge/Google_Cloud-GKE%20%7C%20SQL-4285F4?style=flat-square&logo=googlecloud&logoColor=white)
![Azure](https://img.shields.io/badge/Azure-AKS%20%7C%20SQL-0078D4?style=flat-square&logo=cloudflare&logoColor=white)
![Oracle Cloud](https://img.shields.io/badge/Oracle_Cloud-OKE%20%7C%20DB-F80000?style=flat-square&logo=cloudways&logoColor=white)
![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-pipelines-2088FF?style=flat-square&logo=githubactions&logoColor=white)
![GitLab CI](https://img.shields.io/badge/GitLab_CI-pipelines-FC6D26?style=flat-square&logo=gitlab&logoColor=white)
![Make](https://img.shields.io/badge/Make-4.3-000000?style=flat-square&logo=make&logoColor=white)
![Auto Release](https://img.shields.io/badge/CI-auto--release_to_GitHub-22c55e?style=flat-square&logo=githubactions&logoColor=white)
---
@@ -87,7 +65,6 @@ Architectural overview and technical reference for the Agent Dashboard system, c
- [Update Notifier Subsystem](#update-notifier-subsystem)
- [Tabby Companion Subsystem](#tabby-companion-subsystem)
- [VS Code Extension Architecture](#vs-code-extension-architecture)
- [Desktop App Architecture (macOS & Windows / Electron)](#desktop-app-architecture-macos--windows--electron)
- [Security Considerations](#security-considerations)
- [Performance Characteristics](#performance-characteristics)
- [Deployment Modes](#deployment-modes)
@@ -348,8 +325,7 @@ graph TD
| `routes/agents.js` | CRUD with status/session_id filtering. PATCH broadcasts `agent_updated`. Agent-list responses (`GET /api/agents`, `GET /api/sessions/:id/agents`) attach a per-agent `cost` via `pricing.attachAgentCosts` — each subagent's OWN cost, computed from its `metadata.tokens` at current rates (main agents get 0; their cost is the session total), so a subagent card shows only what that subagent spent rather than the session total |
| `routes/events.js` | Read-only event listing with session_id filter and pagination |
| `routes/stats.js` | Single aggregate query returning total/active counts + status distributions |
| `routes/metrics.js` | Prometheus / OpenMetrics text-exposition endpoint (`GET /api/metrics`) — re-exposes the dashboard's live counters (sessions/agents by status, event + token totals, connected WebSocket clients, configured remote sources, process uptime/RSS, build version) in the v0.0.4 text format for scraping into Prometheus / Grafana. Read-only; reads the same `db.js` prepared statements the REST API uses, so numbers match the UI. Status series are enumerated so a gauge never drops out at zero. Mounted under `/api`, so it sits behind the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard — a non-loopback scraper needs `DASHBOARD_ALLOWED_HOSTS` (+ token if set). A turnkey Prometheus + Grafana stack with four auto-provisioned dashboards lives in `monitoring/` (`npm run monitoring:up` or `npm run docker:full:up`) |
| `monitoring/` | Optional npm-managed or Docker Compose Prometheus + Grafana stack that scrapes `GET /api/metrics`. Ships four Grafana dashboards (`ccam-overview`, `ccam-sessions-agents`, `ccam-tokens-events`, `ccam-platform`), recording rules (`prometheus/ccam-rules.yml`), a Prometheus 3.x-compatible static HTML console (`prometheus/consoles/index.html`), and lifecycle scripts (`monitoring:install`, `monitoring:up`, `monitoring:verify`). See [`monitoring/README.md`](./monitoring/README.md) |
| `routes/metrics.js` | Prometheus / OpenMetrics text-exposition endpoint (`GET /api/metrics`) — re-exposes the dashboard's live counters (sessions/agents by status, event + token totals, connected WebSocket clients, configured remote sources, process uptime/RSS, build version) in the v0.0.4 text format for scraping into Prometheus / Grafana. Read-only; reads the same `db.js` prepared statements the REST API uses, so numbers match the UI. Status series are enumerated so a gauge never drops out at zero. Mounted under `/api`, so it sits behind the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard — a non-loopback scraper needs `DASHBOARD_ALLOWED_HOSTS` (+ token if set) |
| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning |
| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` |
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
@@ -374,10 +350,22 @@ graph TD
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
| `lib/stream-json-parser.js` | Newline-delimited JSON line buffer for parsing `claude --output-format stream-json` output. Reassembles arbitrarily chunked stdout into discrete envelopes. Robust: malformed lines are reported via an `onError` callback but never throw |
| `lib/run-spawner.js` | Spawns and supervises `claude` subprocesses for the Run page. Two modes: **headless** (`-p "<prompt>"` in argv, stdin closed, exits after one turn) and **conversation** (`--input-format stream-json`, prompt + follow-ups piped over stdin, multi-turn). Conversation mode also supports `resumeSessionId``--resume <id>`; an empty `prompt` is permitted in this case (the spawner skips the initial stdin write so `claude` idles on the resumed transcript until the user POSTs a follow-up via `/run/:id/message`). The argv builder also passes through an optional `effort` (`low`/`medium`/`high`) → `--effort`. Output is always `--output-format stream-json --verbose --include-partial-messages` so the parser yields character-level deltas (`stream_event` envelopes) the UI can render token-by-token; each envelope is broadcast as `run_stream` over the existing WebSocket. Status transitions broadcast as `run_status`. A failed spawn records an actual-exit timestamp too: no child started, so lane teardown can safely proceed instead of waiting for a nonexistent `exit` event. SIGTERM escalation checks that timestamp rather than Node's delivery-acknowledgement `child.killed`, so a child that ignores SIGTERM still receives SIGKILL after five seconds. Concurrency is effectively uncapped (default ceiling 10000 — matches the terminal TUI which has no cap; the cap is sanity-only to prevent fork-bomb footguns from a buggy client; override with `RUN_MAX_CONCURRENT`, NaN-safe). Per-handle bounded envelope log (cap 500) lets late-attaching clients replay history via `?envelopes=1`. The Run page additionally reconciles this in-memory log against the session's on-disk JSONL transcript on every attach (incl. clicking Resume / View on a row) — when the transcript has more user/assistant messages than the spawner saw (e.g., a resumed run whose prior history never traversed stdout), it supersedes; otherwise the spawner's log wins (it has stream_event deltas the transcript doesn't carry until each turn finalizes). This is what makes leaving a resumed run and coming back show the same chat the user saw initially. Completed handles reaped after 5 min; full transcripts persist via the normal hook ingestion pipeline because every spawned `claude` fires hooks like any other CLI session |
| `routes/run.js` | HTTP surface for the Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). cwd sanitization: must be absolute and exist as a directory. `GET /` lists handles + concurrency state. `GET /binary` probes whether `claude` is on `PATH`. `GET /cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /files?cwd=&q=` powers the Run page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /` spawns (accepts `effort` in body). `POST /:id/message` sends a follow-up turn. `GET /:id` returns the handle; `?envelopes=1` includes the in-memory envelope log for re-attach. `DELETE /:id` SIGTERMs (escalates to SIGKILL after 5 s) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug` and `base_branch` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. |
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `ccam-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `ccam lanes shell` CLI, etc.), all synced live |
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.ccam/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. |
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and CCAM, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.ccam/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced**`$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
| `lib/lane-runtime.js` | Lane stack lifecycle. `upLane` runs `boot` then `health` (never `bootstrap` — that belongs to provision/reset) and leaves processes running on a failed health check, because their logs are the evidence. Before `boot` it also repairs `.env`, ensures the database exists, migrates every boot, and seeds only the boot that created the database (A2). `downLane` kills each recorded pid tree bottom-up (a parent killed first reparents children to init) and only sweeps port listeners when a pid file existed — an unconditional sweep would kill a server the user started on a lane's stale port. `runtimeFacts` **computes** liveness on every read from pid files and probes rather than caching it (plus the database name/Redis index, never a connection string), which is both correct when a process dies unobserved and why adopting a stack after a dashboard restart needs no code at all. `provisionLane`/`resetLaneData`/`removeLaneData` (A2) drive the data-isolation lifecycle at worktree-provision, reset, and remove time — see `docs/LANES.md#data-isolation-database-redis-and-env-a2`. Writes `slot`/`ports` and nothing else on the lane row: `status=running` means an agent is working, not that a server is listening, and conflating them would corrupt lane liveness |
| `lib/lane-detect.js` | (A3) Node.js project detection and `.ccam/profile/` scaffolding. `detectNode` reads `package.json` layout (root, or `backend/`+`frontend/`) and `docker-compose.yml` (a service matching `/postgres/i` or `/redis/i`) — read-only, never executes anything. Any detected name written into GENERATED SHELL TEXT (an npm script name, a compose service name) must pass a strict identifier check first; a `profile.env` value is already safe regardless, since that file is parsed, never sourced. `scaffoldProfile` writes the profile, always leaving `migrate`/`seed` as an `exit 0` TODO stub when a database is found rather than guessing a migration tool. `checkProfile` is the read-only hard gate: unresolved `TODO:`, a missing/non-executable hook, or a port already in use all fail it; a missing `~/.ccam/secrets.env` only warns |
| `lib/secrets.js` | (A2) Reads `~/.ccam/secrets.env` — machine-level database/Redis credentials, deliberately outside any repository. Parsed with `lane-profile.js`'s literal `KEY=VALUE` reader, never sourced. Falls back to local defaults (with a one-time warning) when the file is absent; refuses to load a file readable by group or world rather than trusting it. Never returned by any route |
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — CCAM stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and CCAM can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `ccam lanes proof-link` |
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `ccam lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
### API Documentation
@@ -636,7 +624,7 @@ graph LR
| `/analytics` | Analytics | `GET /api/analytics` |
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Spawns `claude` subprocesses with chat-style streaming UI, tied to lanes: the UI opens on a `cwd`, calls `POST /api/lanes/ensure` when no lane owns it yet, then starts runs through `POST /api/lanes/:id/start` (which accepts `mode: "conversation" \| "headless"` and `effort: "low" \| "medium" \| "high"`). **A finished run releases its lane** (clears `run_id`, returns status to `idle`). Displays a horizontal lane strip at the top, the selected lane's pipeline map, and run configuration/console/history below. `GET /api/run/{binary,cwds,files}` for pre-flight + `@`-file autocomplete; `POST /api/run/:id/message` for follow-up turns; `DELETE /api/run/:id` to stop (lane-tied runs go through `POST /api/lanes/:id/stop` instead). `GET /api/run/history?laneId=<n>` lists only that lane's runs. WS messages: `run_stream` (includes `stream_event` deltas), `run_status`, `run_input_ack`, `lane_update`. Streaming pipeline: each WS envelope is dispatched through `flushSync` so React 18 doesn't batch bursts into a single render; a `useTypewriterEnvelopes` hook drips text/thinking deltas via `requestAnimationFrame` so even short replies type in; the merge code preserves `_streaming` and the delta-accumulated content array when claude's canonical `assistant` envelope arrives mid-stream so thinking blocks aren't dropped. Tier 1 TUI parity: collapsible-to-pill limitations banner, slash + `@`-file autocomplete (dropdowns open upward, slash matching uses tiered scoring), live token / context-window meter, status header. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title |
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title |
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
| `/*` | NotFound | None (static 404 page) |
@@ -1107,6 +1095,39 @@ flowchart TD
**Preserves existing hooks** -- only adds or updates entries containing `hook-handler.js`.
### Plugin installs declare the same hooks instead
When the dashboard is installed as the `ccam` Claude Code plugin, the eight hook
entries are declared inline in `.claude-plugin/plugin.json` (each running
`${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js`), and `install-hooks.js` is not
used at all. `claude plugin install` itself materializes these into
`~/.claude/settings.json` with `${CLAUDE_PLUGIN_ROOT}` already resolved to the
plugin's cache path — confirmed by installing the plugin for real and
inspecting the file, not just reading the docs. Both a checkout install and a
plugin install writing hooks at once would POST every event twice — events
carry no id, so ingest cannot deduplicate them, and every token and cost figure
would double. Two guards keep that from happening silently:
- `scripts/plugin-bootstrap.js` strips checkout-style `hook-handler.js` entries
out of `~/.claude/settings.json` on session start (backing the file up as
`settings.json.ccam-bak` first) and logs what it removed. Because the
plugin's own entries ALSO contain `hook-handler.js` (just resolved to a cache
path instead of a raw filesystem one), a plain substring match cannot tell
them apart — `isCheckoutHookEntry()` only treats an entry as removable when
its command does NOT resolve under `~/.claude/plugins/cache/`, so the
plugin's own legitimate hooks are never touched.
- `install-hooks.js` warns when the plugin runtime state exists, and
`/ccam-doctor` reports any surviving checkout-style duplicates as a `FAIL`
using the same `isCheckoutHookEntry()` predicate.
`scripts/plugin-bootstrap.js` also owns the rest of the plugin's runtime: the
Node >= 22.5 gate (`node:sqlite`), an atomic `mkdir` lock, the dependency
install into `~/.claude/agent-dashboard/runtime/`, the `~/.local/bin/ccam`
launcher, and the detached server spawn with `NODE_PATH` and
`DASHBOARD_CLIENT_DIST` pointed at that runtime directory. It never writes into
the plugin cache, which Claude Code garbage-collects and replaces on every
update. Full behavior: [`docs/PLUGINS.md`](docs/PLUGINS.md).
---
## Import Pipeline
@@ -1967,7 +1988,7 @@ Cache versioning is controlled by the `CACHE_NAME` constant (`dashboard-v2`). On
`client/src/main.tsx` snapshots `navigator.serviceWorker.controller` before registration and listens for `controllerchange`: when a new SW activates on an already-controlled page, it reloads exactly once so the page picks up the new asset URLs without a hard refresh. The first install (no previous controller) does **not** reload.
These behaviors are reinforced by explicit `Cache-Control` headers from the production Express static middleware in `server/index.js`: `immutable, max-age=31536000` for `/assets/*`; `no-cache, must-revalidate` for `index.html`, `sw.js`, and `manifest.json`; a short revalidation window for other static files. The SPA fallback `sendFile` sends the same `no-cache` header. The native desktop shell (macOS and Windows) loads the dashboard from this same in-process server (`NODE_ENV=production`), so it inherits the policy automatically.
These behaviors are reinforced by explicit `Cache-Control` headers from the production Express static middleware in `server/index.js`: `immutable, max-age=31536000` for `/assets/*`; `no-cache, must-revalidate` for `index.html`, `sw.js`, and `manifest.json`; a short revalidation window for other static files. The SPA fallback `sendFile` sends the same `no-cache` header.
---
@@ -2264,299 +2285,6 @@ For the extension source code, refer to the [vscode-extension/](./vscode-extensi
---
## Desktop App Architecture (macOS & Windows / Electron)
The `desktop/` workspace ships the dashboard as a **native desktop app** for **macOS** (`Claude Code Monitor.app`, distributed as a `.dmg`) **and Windows** (`Claude Code Monitor.exe`, distributed as an NSIS installer plus a no-install portable build). It is an Electron shell that **embeds the existing Express server in-process** and renders the already-built React client in a `BrowserWindow`. The desktop app does not reimplement the dashboard -- it `require()`s `server/index.js` directly, in the same Node runtime as the Electron main process, and points a Chromium window at it.
For the user-facing guide (download, install, Gatekeeper / SmartScreen, tray menu, auto-start), see [DESKTOP.md](./DESKTOP.md). For the full contributor/architecture reference -- including build performance, code signing, notarization, and CI details -- see [desktop/README.md](./desktop/README.md).
### Workspace Position
`desktop/` is a **sibling workspace**, not an npm-workspaces conversion. It has its own `package.json`, its own `node_modules`, and its own TypeScript toolchain. It pins **Electron 35** (bundled Node 22.16). It consumes the rest of the repo as plain files and touches no other workspace's runtime behavior.
```mermaid
flowchart TD
subgraph repo["Claude-Code-Agent-Monitor (repo root)"]
server["server/<br/>Express API · SQLite · WebSocket"]
client["client/<br/>React + Vite SPA"]
scripts["scripts/<br/>hook installer/handler, import, seed"]
mcp["mcp/<br/>local MCP server"]
vscode["vscode-extension/"]
desktop["desktop/<br/>Electron shell (sibling workspace)"]
end
desktop -->|"require() in-process"| server
desktop -->|"loads built SPA from"| client
desktop -->|"auto-installs hooks via"| scripts
server -->|"serves static"| client
style desktop fill:#1f6feb,stroke:#1158c7,color:#fff
style server fill:#238636,stroke:#196c2e,color:#fff
```
The **only** change outside `desktop/` is a behavior-preserving refactor of `server/index.js` (see [Background Services & Hook Bootstrap](#background-services--hook-bootstrap-1) below). `client/`, `scripts/`, `mcp/`, and `vscode-extension/` are untouched.
### Process Model
Electron runs a **main process** (Node.js) and one or more **renderer processes** (Chromium). In this app:
- The **main process** hosts the embedded Express server _and_ manages the window, tray, and menus. There is **no child process and no IPC** for the server -- it runs inside the main process's own event loop.
- The **renderer** is plain Chromium loading `http://127.0.0.1:<port>` -- exactly the same origin a normal browser would use. `preload.ts` is intentionally empty (`contextIsolation: true`, `nodeIntegration: false`, `webSecurity: true`), so the renderer has **zero privileged surface**.
```mermaid
flowchart LR
subgraph main["Electron Main Process (Node 22 / Electron 35)"]
boot["main.ts<br/>lifecycle"]
host["server-host.ts<br/>embedded server"]
express["server/index.js<br/>Express + WS + SQLite"]
tray["tray.ts"]
menu["menu.ts"]
host --> express
boot --> host
boot --> tray
boot --> menu
end
subgraph renderer["Renderer Process (Chromium)"]
win["BrowserWindow<br/>React dashboard"]
preload["preload.ts<br/>(empty -- no bridge)"]
end
express -->|"http + ws on 127.0.0.1:port"| win
win -.->|loads| preload
hooks["Claude Code hooks<br/>(separate node processes)"] -->|"POST /api/hooks/event"| express
style main fill:#0d1117,stroke:#30363d,color:#e6edf3
style renderer fill:#161b22,stroke:#30363d,color:#e6edf3
```
### In-Process Server Hosting
`server-host.ts` is the **only file** that imports `server/index.js`. The dashboard server already exports `{ createApp, startServer, startBackgroundServices }` and serves the built React client (`client/dist`) as static assets in production -- so the host imports that module directly, with no child process, no IPC, and no port marshalling.
| Component | Responsibility |
| --- | --- |
| **`main.ts`** | Main-process entry. Single-instance lock, app menu + tray wiring, dashboard window, `Restart Server`, lifecycle (`window-all-closed`, `before-quit`). |
| **`server-host.ts`** | In-process Express boot: port discovery, adoption, `better-sqlite3` ABI patch, `startBackgroundServices()` + hook bootstrap, clean DB close. Returns a `ServerHandle`. |
| **`window.ts`** | `BrowserWindow` with persisted geometry (`userData/window-state.json`). External links open in the system browser. Sets the window/taskbar `icon` to the colored app logo (`assets/icon.ico` on Windows, `icon.png` elsewhere) so an unpackaged `desktop:dev` run no longer shows the generic Electron icon. |
| **`menu.ts` / `tray.ts`** | Native application menu and menu-bar / notification-area (tray) icon. `tray.ts` selects a platform tray image — a macOS template glyph that the OS tints for the menu bar, or the colored `assets/icon.ico` for the Windows notification area (a black template would vanish on the dark taskbar). Tray uses a single-click dropdown (left or right) with a **live status snapshot** queried straight from SQLite at click time — server port, active sessions, working agents, events today — followed by *Open Dashboard*, *Open in Browser*, *Restart Server*, *Show Logs*, *Open at Login* (toggle), and *Quit*. The menu is rebuilt on each open so every value stays current. Snapshot rows are enabled and click-to-open-dashboard rather than disabled (which the OS dims). The application menu's *File ▸ Open Dashboard* (⌘1) is **macOS-only** — there the global menu bar persists after the window hides; on Windows/Linux the window-attached menu can't reopen a hidden window, so reopening is the tray's job. `focusOrCreateWindow` calls `show()` unconditionally so the tray reliably raises a backgrounded/minimized window (a bare `focus()` on Windows often only flashes the taskbar). |
| **`login-item.ts`** | Auto-start-at-login toggle through Electron's first-party `app.setLoginItemSettings` API on every platform. On macOS it drives the modern `SMAppService` Login Items (not a `LaunchAgent` plist); on Windows it writes a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry. Login launches are tagged with a `--ccam-hidden` arg so the app can stay tray-only at startup — Windows has no `wasOpenedAtLogin` signal, so the arg is the cross-platform detection mechanism. |
| **`shell-path.ts`** | (macOS) Recovers the user's login-shell `PATH` at startup and merges it onto `process.env.PATH`, so the embedded server (and the `claude` CLI it spawns) is not limited to launchd's minimal `PATH`. On Windows the process already inherits the full user `PATH`, so no recovery is needed. |
| **`logger.ts`** | File logger to `~/Library/Logs/Claude Code Monitor/desktop.log` (macOS) or `%APPDATA%\Claude Code Monitor\logs\desktop.log` (Windows) -- the main process has no console when launched from Finder / Explorer. |
| **`constants.ts`** | Shared identifiers, including the `APP_ID` (`com.vn.smartgift.ccam.desktop`) that `main.ts` sets as the Windows AppUserModelId. |
`server-host.ts` resolves the directory containing the bundled `server/` and `client/dist/` via `resolveAppRoot()`: `process.resourcesPath/app` when packaged, or the repo root (one directory up from `desktop/`) in development.
The `ServerHandle` returned to `main.ts`:
```ts
interface ServerHandle {
url: string; // e.g. "http://127.0.0.1:4820"
port: number;
ownedByUs: boolean; // false when an existing server was adopted
stop: () => Promise<void>;
}
```
### Port Discovery & Adoption
On startup `server-host.ts` picks a port, then either adopts an already-healthy server or boots its own. **Adoption** -- `probePort()` connects to `:4820`, then checks that the listener answers `GET /api/health` with `{ status: "ok" }`. If a healthy dashboard server is already running there (e.g. the user ran `npm start` in a terminal), the desktop app **adopts** it rather than double-binding -- no SQLite contention. An adopted server is not owned by the app, so quitting the app leaves it running.
```mermaid
flowchart TD
start["startEmbeddedServer()"] --> forced{"CCAM_DESKTOP_BIND_PORT set?"}
forced -->|yes| bind["bind exactly that port<br/>(no adoption, no fallback)"]
forced -->|no| adopt{"healthy server<br/>already on :4820?"}
adopt -->|yes| reuse["adopt it<br/>ownedByUs = false"]
adopt -->|no| pick["pickFreePort()"]
pick --> p1{":4820 free?"}
p1 -->|yes| use4820["use 4820"]
p1 -->|no| p2{"any of<br/>:4821:4829 free?"}
p2 -->|yes| usefb["use that"]
p2 -->|no| p3{"any of<br/>:49152:49500 free?"}
p3 -->|yes| userand["use that"]
p3 -->|no| fail["throw — no free port"]
bind --> bootsrv["createApp() + startServer()"]
use4820 --> bootsrv
usefb --> bootsrv
userand --> bootsrv
bootsrv --> healthy["waitForHealthy()<br/>poll /api/health ≤ 30s"]
healthy --> bg["bootstrapOwnedServer()"]
bg --> handle["ServerHandle ownedByUs = true"]
reuse --> handleR["ServerHandle ownedByUs = false"]
style reuse fill:#9e6a03,stroke:#7d5300,color:#fff
style fail fill:#da3633,stroke:#b62324,color:#fff
```
Port preference order is **4820 → 48214829 → a random port in 4915249500**. Two environment overrides exist primarily for testing: `CCAM_DESKTOP_BIND_PORT` binds an exact port (disabling adoption and fallback, used by the smoke test), and `CCAM_DESKTOP_NO_ADOPT=1` always starts a fresh server. Before `require()`ing the server module, the host sets `NODE_ENV=production`, `DASHBOARD_PORT=<port>`, and `DASHBOARD_DATA_DIR=<userData>/data` (see [Writable Data Directory](#writable-data-directory) below) so the server reads them from `process.env`.
### Writable Data Directory
A packaged install directory is **read-only** in practice: on macOS a `.app` bundle installed under `/Applications`, code-signed, or run through **app translocation** cannot write to `Resources/app/`, and on Windows the NSIS install dir under `%ProgramFiles%` (or the read-only mount a portable build runs from) is no place for mutable state. The dashboard's SQLite database and the VAPID keypair (`server/lib/push.js`) are writable state, so they must not live inside the bundle / install dir. Before booting the embedded server, `server-host.ts` creates `app.getPath('userData')/data` and points the server at it via the `DASHBOARD_DATA_DIR` environment variable:
- `server/db.js` honors `DASHBOARD_DATA_DIR` for the SQLite file.
- `server/lib/push.js` honors it for the persisted VAPID keys.
The resulting per-user location is `~/Library/Application Support/Claude Code Monitor/data/` on macOS and `%APPDATA%\Claude Code Monitor\data\` on Windows. Because this lives outside the bundle / install dir, imported history and persisted events **survive an app reinstall or update** (the Windows NSIS uninstaller keeps this data by default). Without this, writing a database into the read-only install location failed on a packaged build and broke History Import and event persistence.
The standalone `node server/index.js` path is **unaffected**: `DASHBOARD_DATA_DIR` is unset there, and `server-host.ts` only sets it when it is not already defined -- so `server/db.js` falls back to its usual repo-relative default.
### Shell `PATH` Recovery (macOS)
This step is **macOS-only**. A macOS app launched from Finder, the Dock, or Login Items auto-start is spawned by `launchd`, which hands it a **minimal `PATH`** (roughly `/usr/bin:/bin:/usr/sbin:/sbin`) and does **not** source the user's shell profile. The dashboard's "Run Claude" feature (`server/routes/run.js`, `server/lib/run-spawner.js`) spawns the `claude` CLI, which is almost always installed somewhere only the shell `PATH` knows about (`/opt/homebrew/bin`, `~/.local/bin`, `~/.claude/local`, a Node version-manager's bin dir). Under launchd's `PATH`, `claude` cannot be resolved or spawned.
`shell-path.ts` repairs this **before the server boots**: at startup it runs the user's login+interactive shell once (`$SHELL -ilc`, so `.zprofile`/`.zshrc` are sourced), captures the resulting `PATH` between sentinel markers, and merges it -- plus a fallback list of common CLI install directories -- onto `process.env.PATH`. The merge is order-preserving and deduplicated, so it is idempotent. Because the embedded server runs in the same process, it and every `claude` it spawns inherit the corrected `PATH`. (A `claude` shell _alias_ or _function_ still cannot be spawned -- only a real executable on the `PATH` can.)
On **Windows** there is no equivalent step: a process launched from Explorer, the Start menu, or the `HKCU\…\Run` startup entry already inherits the full user `PATH`, so the embedded server can resolve `claude` directly.
### `better-sqlite3` Native-Module Handling
`better-sqlite3` is the only **native** module in the dependency tree, and a native module must be compiled against the exact Node ABI it runs on. The repo-root copy is built for the **system Node** (so `npm run test:server` works for contributors); Electron ships its **own Node ABI**.
The desktop workspace solves this without disturbing the root install: the desktop workspace has its own `better-sqlite3`, rebuilt for Electron's Node ABI by `electron-builder install-app-deps` (run in its `postinstall`). `server-host.ts` then installs a one-time, **process-local** patch to `Module._resolveFilename` that redirects `require("better-sqlite3")` -- from anywhere in the embedded server -- to that ABI-correct copy.
Desktop setup is **guarded** so a missing or unbuilt binary never escapes as a raw node-gyp trace or a runtime crash: `desktop/scripts/preflight.js` (shared by `install.js` and `prebuild.js`) verifies the Electron-ABI binary exists and, when it doesn't, prints actionable, copy-pasteable setup help -- the per-OS C++ toolchain prerequisites (or a no-toolchain alternative that fetches Electron's prebuilt binary directly) -- before exiting non-zero. `desktop:install` runs this on install; the `prebuild` gate enforces it before every `desktop:*` build, turning a would-be runtime failure into a build-time error.
```mermaid
flowchart TD
subgraph desk["desktop/node_modules"]
d1["better-sqlite3<br/>rebuilt for Electron's ABI<br/>(electron-builder install-app-deps)"]
end
subgraph root["node_modules (repo root)"]
r1["better-sqlite3<br/>built for system Node<br/>(used by npm run test:server)"]
end
patch["ensureNativeModulesPatched()<br/>overrides Module._resolveFilename"]
srv["server/db.js<br/>require('better-sqlite3')"]
srv -->|"request intercepted"| patch
patch -->|"redirected to"| d1
patch -.->|"everything else<br/>passes through"| root
style d1 fill:#238636,stroke:#196c2e,color:#fff
style patch fill:#1f6feb,stroke:#1158c7,color:#fff
```
- The patch is installed exactly once, **before** `server/index.js` is `require()`d, and rewrites _only_ `require("better-sqlite3")` -- every other module resolves normally.
- `electron-builder.yml` therefore **excludes** the root `better-sqlite3` from the bundle (it would trip `@electron/universal`'s identical-file detector) and `asarUnpack`s the desktop copy (native `.node` files cannot live inside an `asar` archive).
- The `compat-sqlite` (`node:sqlite`) fallback remains a safety net -- one reason the desktop app pins **Electron 35**, whose bundled Node 22.16 has `node:sqlite`.
### Background Services & Hook Bootstrap
`node server/index.js` runs its production bootstrap from an `if (require.main === module)` block. Because the desktop app **`require()`s** that module, the block never fires -- so the bootstrap was extracted into an exported `startBackgroundServices()` that both paths call. This is a **behavior-preserving refactor** of `server/index.js`: the standalone server path is functionally unchanged.
```mermaid
flowchart LR
subgraph standalone["node server/index.js"]
s1["require.main === module"] --> s2["startBackgroundServices()"]
end
subgraph desktopapp["desktop app"]
d1["server-host.ts<br/>bootstrapOwnedServer()"] --> d2["startBackgroundServices()"]
d1 --> d3["installHooks()"]
end
d2 --> svc
s2 --> svc
subgraph svc["Background services"]
u["update scheduler"]
w["cc-watcher (Claude config watcher)"]
r["orphaned-run reconciliation"]
end
style d1 fill:#1f6feb,stroke:#1158c7,color:#fff
```
`bootstrapOwnedServer()` runs **once** -- guarded by a module-level flag so a `Restart Server` does not double-register schedulers or watchers -- and:
1. Calls `startBackgroundServices()` -- the update scheduler, the `cc-watcher` config watcher, and one-time orphaned-run reconciliation.
2. Calls `installHooks()` -- writes the Claude Code hook configuration to `~/.claude/settings.json`, so an install-only user (DMG on macOS, `.exe` on Windows) gets events flowing without ever running `npm run install-hooks` from a checkout.
It runs only when the server is **owned** by the app -- an adopted server has already done its own bootstrap.
### App Lifecycle
```mermaid
sequenceDiagram
autonumber
participant OS as macOS / Windows
participant Main as main.ts
participant Host as server-host.ts
participant Srv as server/index.js
participant UI as BrowserWindow
OS->>Main: launch app
Main->>Main: setAppUserModelId (win32) · requestSingleInstanceLock()
alt lock not acquired
Main->>OS: exit(0) — focus existing instance
end
Main->>Host: ensureUserPath() — recover login-shell PATH (macOS only)
Main->>Host: startEmbeddedServer()
Host->>Host: probe :4820 — adopt if a healthy server answers
alt no server to adopt
Host->>Host: pickFreePort() · set DASHBOARD_DATA_DIR · patch better-sqlite3 ABI
Host->>Srv: require() · createApp() · startServer(port)
Host->>Srv: waitForHealthy() — poll /api/health ≤ 30s
Host->>Srv: bootstrapOwnedServer() — schedulers, cc-watcher, install hooks
end
Host-->>Main: ServerHandle { url, port, ownedByUs, stop }
Main->>Main: installApplicationMenu() · createTray()
alt launched at login (--ccam-hidden / wasOpenedAtLogin)
Main->>OS: stay tray-only, hide dock (macOS)
else normal launch
Main->>UI: createDashboardWindow(url)
UI->>Srv: GET http://127.0.0.1:port
end
Note over Main: window "close" → hide (server keeps running)
Note over Main: ⌘Q / Ctrl+Q → confirm (second press bypasses)
Note over Main: before-quit → stop owned server + closeEmbeddedDatabase()
```
| Event | Behavior |
| --- | --- |
| **Second launch** | `requestSingleInstanceLock()` (enabled on **every platform**) fails -- the new process exits and the existing window is focused. |
| **Window close** | Intercepted -- the window **hides** (`hide()`); the server and tray keep running. |
| **`window-all-closed`** | App stays alive in tray-only mode (the handler is intentionally a no-op). |
| **Launched at login** | The dashboard window is **not** shown -- only the tray icon. Detected via macOS `wasOpenedAtLogin` (dock hidden, `openAsHidden`) or, on Windows, the `--ccam-hidden` arg written into the `HKCU\…\Run` startup command. |
| **Quit shortcut** | ⌘Q (macOS) / Ctrl+Q (Windows) shows a confirmation dialog; a second press bypasses it. |
| **`before-quit`** | If the server is owned: stop the HTTP server, then `closeEmbeddedDatabase()` for a clean WAL checkpoint, then `app.exit(0)`. The DB handle is closed here -- never on `Restart Server`, where the cached `server/db.js` singleton must stay usable. |
### Packaged App Layout
`electron-builder` produces `Claude Code Monitor.app` on macOS and `Claude Code Monitor.exe` (NSIS installer + portable) on Windows. On both platforms the Electron main-process code is compiled (`tsc``out/`) and packed into `app.asar`; the rest of the repo is shipped as **`extraResources`** -- plain files under the bundle's `Resources/app/` (macOS) or the install dir's `resources\app\` (Windows). The internal layout is the same shape on both:
```mermaid
flowchart TD
appbundle["Claude Code Monitor.app (macOS)<br/>Claude Code Monitor install dir (Windows)"]
appbundle --> contents["Contents/ (macOS)<br/>install root (Windows)"]
contents --> macos["MacOS/ — Electron binary (macOS)<br/>Claude Code Monitor.exe (Windows)"]
contents --> res["Resources/ (macOS)<br/>resources\ (Windows)"]
res --> asar["app.asar<br/>(compiled out/**, package.json)"]
res --> unpacked["app.asar.unpacked/<br/>node_modules/better-sqlite3 (.node)"]
res --> appdir["app/"]
appdir --> a1["server/ — Express server (no tests)"]
appdir --> a2["client/dist/ — built React SPA"]
appdir --> a3["scripts/ — hook-handler, install-hooks"]
appdir --> a4["node_modules/ — server runtime deps"]
appdir --> a5["package.json"]
style asar fill:#1f6feb,stroke:#1158c7,color:#fff
style appdir fill:#238636,stroke:#196c2e,color:#fff
```
At runtime `server-host.ts` resolves this root as `process.resourcesPath/app` when packaged, on both platforms. Everything under the packaged `app/` is **read-only** on a packaged, signed, or app-translocated macOS bundle and on a Windows install under `%ProgramFiles%` (or a portable build's mount) -- so all writable state (the SQLite database, VAPID keys) lives in the per-user data dir (`~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows), **never inside the bundle / install dir** (see [Writable Data Directory](#writable-data-directory)).
On macOS `electron-builder` produces **two per-architecture DMGs** — one `arm64` (Apple Silicon), one `x64` (Intel) — via `--mac --arm64 --x64` (not a merged universal binary; the release ships both), ad-hoc signed by default so anyone can build a working `.dmg` without a paid Apple Developer account; real Developer ID signing and notarization are opt-in via environment variables (`CSC_LINK`, `APPLE_ID`, etc.). On Windows it produces an **NSIS installer `.exe`** and a **no-install portable `.exe`** (both x64), using `assets/icon.ico` (generated from the source PNG by `desktop/scripts/build-win-icon.ps1`) as the application and tray icon. **`electron-builder` packages for the host OS** -- DMGs build on macOS, Windows `.exe`s build on Windows -- so the two artifacts come from two CI jobs (see below). The `desktop/scripts/prebuild.js` guard also **self-heals** a `better-sqlite3` native binary that a prior cross-arch DMG build (`electron-builder --mac --x64/--arm64`) left compiled for the wrong CPU architecture -- it detects the mismatch via `file` and re-runs `electron-builder install-app-deps`, so `desktop:dev` and `desktop:test` do not fail with `ERR_DLOPEN_FAILED`; on Windows it shells the `.cmd` shims for `npm`/`npx`. CI runs a path-filtered `🍎 macOS Desktop (DMG)` job on `macos-latest` (artifact `ClaudeCodeMonitor-dmg`) and a `🪟 Windows Desktop (EXE)` job on `windows-latest` (artifact `ClaudeCodeMonitor-win`); the release attaches both. See [`desktop/README.md`](./desktop/README.md) for the full build pipeline, build-performance notes, and signing details.
### Relation to Standalone Deployment
The desktop app is a fourth deployment mode alongside Development, Production, and Container (see [Deployment Modes](#deployment-modes)). The data path is **identical to the standalone Production path** -- Claude Code hooks `POST /api/hooks/event` to the embedded Express server, which writes to SQLite and broadcasts over WebSocket to the renderer. The only structural difference is that the server runs inside the Electron main process instead of a standalone `node server/index.js`, and the renderer is a `BrowserWindow` rather than a browser tab pointed at the same origin.
---
## Security Considerations
| Area | Approach |
@@ -2643,26 +2371,6 @@ graph LR
| **File watching** | `node --watch` + Vite HMR | None |
| **Source maps** | Inline | External files |
### Desktop App (macOS & Windows)
The native desktop app (macOS `.app`/`.dmg`, Windows NSIS / portable `.exe`) is a self-contained deployment mode: a single Electron process embeds the Express server in-process and renders the React client in a `BrowserWindow`. No terminal, no separate `npm start`.
```mermaid
graph LR
LAUNCH["Open Claude Code Monitor<br/>(.app / .exe)"] --> MAIN["Electron main process<br/>(Node 22 / Electron 35)"]
MAIN --> HOST["server-host.ts<br/>port discovery + adopt"]
HOST --> SERVER["server/index.js (in-process)<br/>Port 4820 → fallback"]
SERVER -->|serves| DIST["client/dist/<br/>(extraResources)"]
MAIN --> WIN["BrowserWindow"]
WIN --> SERVER
style MAIN fill:#1f6feb,stroke:#1158c7,color:#fff
style SERVER fill:#339933,stroke:#5cb85c,color:#fff
style DIST fill:#646CFF,stroke:#818cf8,color:#fff
```
The hook ingestion path (Claude Code hooks → `POST /api/hooks/event` → SQLite → WebSocket) is **identical to the standalone Production path** -- only the process that hosts the server differs. See [Desktop App Architecture](#desktop-app-architecture-macos--windows--electron) for the full design.
### MCP Sidecar (Optional)
The MCP server runs as a sidecar alongside the dashboard, connecting to the same API. It supports three transport modes:
@@ -2748,58 +2456,6 @@ docker run -d -p 4820:4820 \
> [!NOTE]
> **Hook note:** Claude Code hooks run on the host, not inside the container. The containerized server still receives hook events via HTTP on `localhost:4820` — run `npm run install-hooks` on the host after the container is up. `scripts/install-hooks.js` detects container execution and refuses there (issue #193) so it cannot write a container-internal handler path into a bind-mounted host `~/.claude`; the containerized server's boot-time auto-install is skipped for the same reason. Override with `CCAM_ALLOW_CONTAINER_HOOKS=1` only when Claude Code itself runs inside the container.
### Cloud Deployment
For production cloud deployments, the `deployments/` directory provides enterprise-grade infrastructure supporting four cloud providers and multiple deployment strategies.
```mermaid
graph TB
subgraph "Deployment Pipeline"
direction LR
CI["CI Pipeline<br/>Build · Test · Scan"] --> DEPLOY["Deployment<br/>Helm · Kustomize · Terraform"]
DEPLOY --> VERIFY["Verification<br/>Health Check · Smoke Tests"]
VERIFY -->|Fail| ROLLBACK["Rollback<br/>Instant Revert"]
end
subgraph "Infrastructure"
direction TB
subgraph "Compute"
BLUE["Blue Slot<br/>Current Version"]
GREEN["Green Slot<br/>New Version"]
end
LB["Load Balancer<br/>TLS 1.3 · WebSocket<br/>Weighted Routing"]
PV["Persistent Storage<br/>Encrypted NFS"]
MON["Monitoring<br/>Prometheus · Grafana<br/>13 Alert Rules"]
OTEL["OTel Collector<br/>Coralogix"]
end
LB -->|"Active"| BLUE
LB -.->|"Standby"| GREEN
BLUE & GREEN --> PV
MON -->|"Scrape"| BLUE & GREEN
BLUE & GREEN -->|"logs + metrics + traces"| OTEL
style BLUE fill:#2563eb,color:#fff
style GREEN fill:#16a34a,color:#fff
style LB fill:#7c3aed,color:#fff
style CI fill:#2088ff,color:#fff
style OTEL fill:#4f46e5,color:#fff
```
| Capability | Details |
| --- | --- |
| **Cloud Providers** | AWS (ECS Fargate + ALB), GCP (Cloud Run + GCLB), Azure (ACI + App Gateway), OCI (OKE + LBaaS) |
| **Deployment Methods** | Helm chart, Kustomize overlays, Terraform modules |
| **Release Strategies** | Rolling update, blue-green (instant switchover), canary (automated analysis) |
| **Environments** | Dev, staging, production with per-environment configuration |
| **CI/CD** | GitHub Actions and GitLab CI pipelines with Trivy security scanning |
| **Observability** | Prometheus scraping, 13 alert rules, Grafana dashboard (16 panels), Alertmanager routing, Coralogix full-stack observability (logs, metrics, traces, SLO tracking) via OpenTelemetry Collector |
| **Operations** | Scripts for deploy, rollback, blue-green switch, database backup/restore, teardown |
| **Security** | Restricted PSS, network policies, TLS enforcement, OIDC auth, no long-lived credentials |
> [!NOTE]
> 📘 **Full guide:** See [DEPLOYMENT.md](DEPLOYMENT.md) for step-by-step deployment instructions, and [deployments/README.md](deployments/README.md) for the infrastructure technical reference.
---
## Statusline Utility
+9 -3
View File
@@ -8,8 +8,9 @@
## Repo map
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
- `client/`: React + Vite UI.
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`; the dashboard never restarts itself — users run the printed command, surfaced in the UI and by `ccam update-check`.)
- `mcp/`: local MCP server exposing dashboard operations as tools.
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`. `POST /api/updates/apply` (only when the checkout is fast-forwardable) pulls, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; otherwise users run the printed manual command, surfaced in the UI and by `ccam update-check`.)
- `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`.
- `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
## Lanes
@@ -22,7 +23,10 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`.
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
- **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history.
- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence.
- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`ccam lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
- **A lane's stack is up or down as a computed fact, never a stored one.** `runtimeFacts` re-derives it from pid files and port probes on every read. A process dies to OOM, a stray `kill`, a reboot — caching a truth CCAM does not control buys ghost state. Slot allocation is the opposite (fully controlled, must be race-free), so that one does live in the DB, under `withLaneLock` plus a partial unique index.
- **A profile's `profile.env` is parsed, never sourced.** Hooks are executed deliberately; config is only read. Sourcing arbitrary shell from a user's repository into the dashboard process would be a code-execution path. Hook names always come from the fixed allowlist in `server/lib/lane-profile.js`, never from a request.
- Pipeline templates are JSON files (`server/data/pipelines/`) with node definitions; custom templates override built-ins when `DASHBOARD_PIPELINES_DIR` is set.
- Nodes render in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped, amber), `pending` (not reached).
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
@@ -46,6 +50,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
- Server tests: `npm run test:server`
- Client tests: `npm run test:client`
- MCP install/build/start: `npm run mcp:install`, `npm run mcp:build`, `npm run mcp:start`
- MCP build freshness: `npm run mcp:check-build` (must pass whenever `mcp/src` changes)
- MCP typecheck: `npm run mcp:typecheck`
- CLI (after setup): `ccam <command>` — terminal access to the full dashboard surface (`bin/ccam.js`; `ccam help` lists commands)
@@ -68,3 +73,4 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
- Use file-specific rules in `.claude/rules/` when working in scoped areas.
- Use project skills from `.claude/skills/` for repeatable workflows.
- Use `.claude/agents/` subagents for focused review or investigation passes.
- **Declare lane stage even in plain chat, not just inside skills.** `ccam stage` (see `docs/LANES.md` § Reporting a stage) is a reporting command, not a skill-only ritual — any Claude session working inside an adopted lane's cwd should call it on real stage transitions (starting to plan, starting to implement, running tests, opening the PR, etc.), whether or not a skill is driving. Tool-event detection (`server/lib/stage-detect.js`) only ever paints the amber "detected" badge, never the blue `current` ring — a lane worked entirely through plain chat with no `ccam stage` calls will show a stale `current` stage no matter how much real work happens. Skip it only when `ccam stage` reports no lane owns the cwd (not adopted).
-1080
View File
File diff suppressed because it is too large Load Diff
-253
View File
@@ -1,253 +0,0 @@
# Claude Code Monitor — Desktop App (macOS & Windows)
The dashboard ships with an optional **native desktop app** (built with Electron 35) that wraps the existing server + client into a single application you install once and forget — a macOS `.app` (shipped as a `.dmg`) and a Windows `.exe` (an NSIS installer plus a no-install portable build). Everything you see in the browser at `localhost:4820` lives inside this window, with native OS lifecycle on top: a menu-bar / notification-area (tray) icon, a native application menu, auto-start at login, and a single quit button that cleans up the server.
## Why this exists in addition to the PWA
The PWA (added in #144) makes the dashboard installable in Chromium-based browsers, which is great for users who already keep the server running. The desktop app solves the orthogonal problem: **starting and keeping the server running** without a terminal window. Concretely:
| Capability | PWA | Desktop App |
|---|---|---|
| Installs to dock / Applications | ✅ | ✅ |
| Manages the Express server | ❌ — user must `npm start` separately | ✅ — embedded in-process |
| Auto-starts at login | ❌ | ✅ via macOS Login Items / Windows `HKCU\…\Run` |
| Menu-bar / notification-area (tray) icon for always-on status | ❌ | ✅ |
| Native application menu (⌘ / Ctrl shortcuts, etc.) | ❌ | ✅ |
| Survives browser restart | ⚠️ depends on browser | ✅ |
The two coexist — install whichever fits your workflow.
## Quick install
**Option A — download a pre-built installer** (recommended):
1. Open [**Releases → latest**](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) and grab the asset for your platform. Every `master` commit that bumps the version in `package.json` cuts a new `vX.Y.Z` release automatically (CI publishes it), so this link always lands on the current build — no GitHub sign-in required.
| Platform | Asset | Notes |
|---|---|---|
| macOS (Apple Silicon) | `ClaudeCodeMonitor-<ver>-arm64.dmg` | drag into `/Applications` |
| macOS (Intel) | `ClaudeCodeMonitor-<ver>-x64.dmg` | drag into `/Applications` |
| Windows (installer) | `ClaudeCodeMonitor-Setup-<ver>-x64.exe` | per-user install, no admin |
| Windows (portable) | `ClaudeCodeMonitor-<ver>-x64-portable.exe` | run without installing |
2. Want a **per-commit build** instead of waiting for a release? Every green CI run uploads a workflow artifact (sign-in required, 14-day retention) — `ClaudeCodeMonitor-dmg` from the `🍎 macOS Desktop (DMG)` job and `ClaudeCodeMonitor-win` from the `🪟 Windows Desktop (EXE)` job:
```bash
gh run download <run-id> -R Smartgift-AI/Claude-Code-Monitor -n ClaudeCodeMonitor-dmg # or ClaudeCodeMonitor-win
```
3. **macOS:** double-click the DMG → drag `Claude Code Monitor.app` into your `Applications` folder. Open it; macOS may show a Gatekeeper warning the first time — see [Gatekeeper & SmartScreen](#gatekeeper--smartscreen-first-launch) below.
4. **Windows:** run `ClaudeCodeMonitor-Setup-<ver>-x64.exe` (per-user, no admin) and follow the wizard, or just run the `*-portable.exe` to launch without installing. Windows **SmartScreen** may show *"Windows protected your PC"* the first time — see [Gatekeeper & SmartScreen](#gatekeeper--smartscreen-first-launch) below.
**Option B — build locally:**
```bash
# In the project root, after `git clone`:
npm run setup # installs root + client + vscode-extension deps
npm run build # builds the React client
npm run desktop:install # installs Electron + electron-builder
# Build for macOS (run ON macOS) — pick one:
npm run desktop:dmg:arm64 # Apple Silicon only — FAST (~1 min); use this for your own Mac
npm run desktop:dmg:x64 # Intel only — FAST
npm run desktop:dmg # BOTH per-arch DMGs (arm64 + x64) — the release build; slower (packages each arch)
npm run desktop:dmg:universal # ONE merged universal DMG (arm64 + x86_64 in a single file) — optional, slowest
# Build for Windows (run ON Windows) — pick one:
npm run desktop:win # NSIS installer → desktop/release/ClaudeCodeMonitor-Setup-<ver>-x64.exe
npm run desktop:win:portable # no-install portable → desktop/release/ClaudeCodeMonitor-<ver>-x64-portable.exe
# electron-builder packages for the HOST OS — you cannot build a Windows .exe
# on macOS or a macOS .dmg on Windows.
# Open the macOS DMG you just built. desktop:dmg:arm64 / :x64 wipe release/ and emit
# one DMG; desktop:dmg wipes release/ and emits both (…-arm64.dmg + …-x64.dmg).
open desktop/release/ClaudeCodeMonitor-*-arm64.dmg # …-x64.dmg for the Intel build
```
> **`desktop:dmg` builds both architectures, so it takes longer.** It packages
> and ad-hoc-signs the app **twice** — once for `arm64`, once for `x64` — and
> emits two separate DMGs (`…-arm64.dmg` + `…-x64.dmg`). It does **not** merge
> them into a single universal binary; the release ships the two per-arch DMGs.
> For running on **your own Mac**, use the arch-specific command
> (`desktop:dmg:arm64` / `desktop:dmg:x64`) — half the work, and it finishes in
> about a minute. CI runs `desktop:dmg` for you and uploads both DMGs as the
> `ClaudeCodeMonitor-dmg` artifact, so you rarely need to build them locally.
## What happens when you launch the app
1. The Electron main process picks a free port — preferring **4820**, falling back to 48214829, then a random high port if all those are taken.
2. If something already answers `/api/health` on port 4820 (e.g. you ran `npm start` in a terminal), the app **adopts that server** and skips starting a second one. No double-binding, no SQLite contention.
3. Otherwise it `require()`s `server/index.js` directly in-process — same Node runtime as the main process, same memory. Boot is typically under two seconds.
4. On startup the server records its **live port** to `~/.claude/.agent-dashboard.json`. The Claude Code hook handler reads that file, so events still reach the dashboard when the app bound a fallback port instead of 4820.
5. The dashboard window opens — unless the app was launched at login (on macOS via Login Items; on Windows via the `HKCU\…\Run` entry, detected through a `--ccam-hidden` launch arg since Windows has no `wasOpenedAtLogin`), in which case it stays tray-only.
6. A tray icon appears — the macOS **menu bar** or the Windows **notification area**. One click opens a dropdown with a **live status snapshot** (server port, active sessions, working agents, events today — all clickable to jump into the dashboard) plus *Open Dashboard*, *Open in Browser*, *Restart Server*, *Show Logs*, *Open at Login* (toggle), and *Quit*.
## Lifecycle semantics
- **Closing the window hides it.** The server keeps running, the tray icon stays, and (on macOS) the **dock icon stays too** — clicking either re-opens the window. Independent signals that the app is still alive.
- **Quitting** (⌘Q / Ctrl+Q, *Quit* in the application menu, or *Quit* in the tray menu) pops a confirmation dialog — *"Quit Claude Code Monitor? Press ⌘Q again to skip this prompt and quit immediately."* Press **Quit** in the dialog, or **press ⌘Q / Ctrl+Q a second time** to bypass the prompt. Either way the SQLite handle is checkpointed cleanly before the process exits.
- **Tray** — the macOS menu bar / Windows notification area. macOS uses a black template glyph the OS tints for light/dark menu bars; Windows uses the colored `icon.ico`, because a template glyph would vanish on the dark taskbar. A single click (left or right) opens the dropdown, which shows a **live status snapshot** pulled straight from the embedded SQLite handle each time it opens: server port, active sessions, working agents, and events today. Snapshot rows are clickable — they open the dashboard. The tray's *Open Dashboard* reliably **raises** the window even when it is minimized or behind other windows. (The application menu's *File ▸ Open Dashboard* / ⌘1 is **macOS-only** — on Windows/Linux a window-attached menu accelerator can't reopen a hidden window, so reopening is the tray's job there.)
- **Window / taskbar icon** — the `BrowserWindow` sets its `icon` to the colored app logo (`icon.ico` on Windows, `icon.png` elsewhere — the same logo as the macOS Dock, rendered from `assets/icon.svg`), so an unpackaged `desktop:dev` run shows the real app logo in the title bar / taskbar instead of the generic Electron icon. The macOS dev Dock icon is set too; packaged apps already get theirs from the bundle `.icns`/`.exe`.
- **Open-at-login toggle:** flip *Open at Login* in the tray menu (or the app menu). Both platforms go through Electron's first-party `app.*LoginItemSettings` API — no third-party deps. On **macOS** it registers via the `SMAppService` API, so the entry appears under → *System Settings → General → Login Items*. On **Windows** it writes a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry, visible under *Task Manager → Startup*; a login-triggered launch is detected via a `--ccam-hidden` arg (Windows has no `wasOpenedAtLogin`). On Linux the toggle is a no-op (unsupported).
- **Single-instance:** double-launching just focuses the existing window. No second server, no port collision. (Applies on every platform.)
- **Logs** live at `~/Library/Logs/Claude Code Monitor/desktop.log` on macOS and `%APPDATA%\Claude Code Monitor\logs\desktop.log` on Windows (use *Show Logs* in the tray menu to open the folder).
- **Your data** (the SQLite database and VAPID keys) lives outside the app bundle / install dir, so it **survives app reinstalls and updates**`~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows. The Windows NSIS uninstaller **keeps this data by default** (`deleteAppDataOnUninstall: false`), mirroring how dragging the `.app` to the Trash on macOS never touches your data.
- **The `claude` CLI on PATH.** On **macOS** the app resolves it using your login-shell `PATH`, recovered at startup — so "Run Claude" works even though a Finder/Dock-launched app would otherwise only inherit a minimal `PATH`. On **Windows** the inherited user `PATH` already includes it, so no recovery is needed.
- **Notifications** (including the in-dashboard *Send test notification* button) are delivered as **native OS toasts** on both platforms when running inside the app — the embedded server calls Electron's `Notification` API directly. On Windows the app sets an `AppUserModelId` (`com.vn.smartgift.ccam.desktop`, matching the electron-builder `appId`) so toasts attribute to the app and its taskbar windows group correctly. Web Push doesn't work reliably inside Electron (Chromium-in-Electron ships without Firebase Cloud Messaging credentials, so `pushManager.subscribe` returns endpoints nothing can deliver to), and this path bypasses it entirely. The web dashboard at `npm start` continues to use Web Push as before.
- **Coexists with the web dashboard.** You can run the desktop app and `npm run dev` (or `npm start`) at the same time. Each server writes its `{port, pid, startedAt, dataDir}` entry to a shared discovery file at `~/.claude/.agent-dashboard.json`, and the Claude Code hook handler POSTs to **one ingest target per unique SQLite data directory** (lowest port wins when both share `~/.claude/agent-dashboard`, so events are never double-ingested). Servers with **different** databases (e.g. the desktop app's Application Support dir alongside `npm run dev`) still each receive hooks and stay real-time.
## File layout (for contributors)
```
desktop/
├── package.json # Electron + electron-builder
├── tsconfig.json
├── electron-builder.yml # macOS (dmg) + Windows (nsis/portable) targets; signing/notarization hooks
├── assets/ # icon.svg + generated icon.icns (macOS) + icon.ico (Windows) + tray PNGs
├── src/
│ ├── main.ts # main process entry, lifecycle; setAppUserModelId on win32
│ ├── server-host.ts # in-process Express boot, port discovery, adopt
│ ├── window.ts # BrowserWindow + persisted state
│ ├── tray.ts # tray icon (platform image: template PNG on macOS, icon.ico on Windows) + context menu
│ ├── menu.ts # native application menu
│ ├── login-item.ts # open-at-login (macOS Login Items + Windows HKCU\…\Run startup)
│ ├── shell-path.ts # recover the user's shell PATH (find `claude`)
│ ├── preload.ts # (empty — kept for future renderer bridges)
│ ├── logger.ts # file logger
│ └── constants.ts # incl. APP_ID (matches electron-builder appId)
├── scripts/
│ ├── install.js # `desktop:install` wrapper: runs npm install, then prints actionable native-dep help + exits non-zero on failure
│ ├── preflight.js # shared native-dep check (hasBetterSqliteBinary) + per-OS prerequisite help (printNativeDepHelp)
│ ├── prebuild.js # ensures root + client are built before tsc; shells npm/npx on Windows (.cmd shims); fails fast with setup help when the better-sqlite3 native binary is missing
│ ├── build-icons.sh # SVG → PNG/ICNS + tray PNGs via qlmanage/sips/iconutil (macOS)
│ ├── build-win-icon.ps1 # icon.png → icon.ico for Windows (PowerShell + .NET)
│ └── notarize.js # electron-builder afterSign hook (opt-in; macOS only)
└── tests/
└── smoke.test.mjs # spawn-and-probe /api/health (resolves the real electron binary via createRequire)
```
**Changes outside `desktop/` are deliberately minimal:**
- `server/index.js` — a behavior-preserving refactor: the post-listen bootstrap (one-time legacy-session import, update scheduler, Claude Code config watcher, orphaned-run reconciliation) was extracted into an exported `startBackgroundServices()` so the embedded server runs exactly what `node server/index.js` runs. The standalone server path is functionally unchanged. (The legacy-session import previously sat in the standalone-only `require.main` block, so the desktop dashboard started empty — moving it into `startBackgroundServices()` fixes that.) It also now publishes its live port via `server/lib/server-info.js` on startup.
- `server/lib/server-info.js` *(new)* — writes/reads the `~/.claude/.agent-dashboard.json` port discovery file.
- `scripts/hook-handler.js` — resolves the dashboard port from the discovery file (falling back to `CLAUDE_DASHBOARD_PORT`, then 4820), so hook events reach the server even when it bound a fallback port.
`client/`, `mcp/`, and `vscode-extension/` are untouched. The Electron main process is otherwise just a host for the same code.
## Gatekeeper & SmartScreen (first launch)
### macOS — Gatekeeper
The DMG is **ad-hoc signed** by default — that's all the project can offer without a paid Apple Developer ID. macOS will warn the first time you open it: *"Apple could not verify…"*.
Two ways past it:
```bash
# Easiest: strip the quarantine attribute from the DMG before opening.
xattr -cr ~/Downloads/ClaudeCodeMonitor-*.dmg
```
Or open → *System Settings → Privacy & Security*, scroll to the blocked DMG, click *Open Anyway*.
### Windows — SmartScreen
The Windows `.exe` (both the installer and the portable build) is **unsigned** by default, so Windows **SmartScreen** may show *"Windows protected your PC"* the first time you run it. Click **More info → Run anyway** to launch it.
Authenticode signing is opt-in for the maintainer: provide a code-signing certificate via `CSC_LINK` (a base64-encoded `.p12`) and `CSC_KEY_PASSWORD` and electron-builder signs the `.exe` automatically — no code change required. A signed build skips the SmartScreen prompt.
### Notarization (for the maintainer)
When you're ready to make this go away for everyone, add these three repository secrets:
| Secret | Where it comes from |
|---|---|
| `APPLE_ID` | Your Apple ID email |
| `APPLE_TEAM_ID` | Your Apple Developer team ID |
| `APPLE_APP_SPECIFIC_PASSWORD` | An app-specific password created at appleid.apple.com |
Optionally, also `CSC_LINK` (base64-encoded `.p12`) and `CSC_KEY_PASSWORD` to provide an explicit Developer ID certificate from outside the runner keychain. The CI workflow picks them up automatically — no code change required. See [`desktop/scripts/notarize.js`](desktop/scripts/notarize.js) for the hook.
> Local builds are **always ad-hoc signed**: the `package` script sets `CSC_IDENTITY_AUTO_DISCOVERY=false`, so a code-signing certificate already in your macOS keychain is never auto-discovered (an Apple Development cert would otherwise be picked up and fail distribution-type signing). Real signing activates only through the explicit `CSC_LINK` certificate above — that path is unaffected by the flag.
## Development workflow
```bash
# Hot-iterate on the main process (rebuilds tsc on save would be next steps;
# v1 ships without watch mode — just re-run desktop:dev after changes):
npm run desktop:dev
# Smoke test (also runs in CI on macOS):
npm run desktop:test
# macOS — single-architecture DMG — fast (~1 min):
npm run desktop:dmg:arm64 # or desktop:dmg:x64 for Intel
# macOS — both per-arch DMGs — slower (builds + signs each architecture):
npm run desktop:dmg
# macOS — one merged universal DMG (arm64 + x86_64 in a single file) — optional, slowest:
npm run desktop:dmg:universal
# Windows — NSIS installer / no-install portable (run ON Windows):
npm run desktop:win # NSIS installer .exe
npm run desktop:win:portable # no-install portable .exe
```
> electron-builder packages for the **host OS** — build DMGs on macOS and the
> Windows `.exe`s on Windows. The Windows icon regenerates from `icon.png` with
> `npm run build:win-icon` (PowerShell + .NET); the macOS icns + tray PNGs come
> from `npm run build:icons`. On Windows, `better-sqlite3` is fetched as a
> prebuilt Electron binary by `npm run desktop:install` (its postinstall runs
> `electron-builder install-app-deps`), so no Visual Studio C++ toolchain is
> needed in the common case. If that fetch/rebuild *does* fail (no C++ toolchain,
> or a Node version with no prebuilt binary), `npm run desktop:install` — and any
> `desktop:*` build, gated by `prebuild.js` — prints the exact per-OS fix plus a
> no-toolchain alternative and **fails loudly** rather than crashing at runtime:
>
> ```bash
> cd desktop
> npm install --ignore-scripts
> node node_modules/electron/install.js
> npx electron-builder install-app-deps
> ```
>
> A Node LTS (20/22) ships prebuilt `better-sqlite3` binaries and avoids the
> compile entirely.
> After `npm run clean` in `desktop/`, you must `npm run build` again before
> packaging — `clean` removes `out/`, and `electron-builder` only packages, it
> does not compile. The `desktop:dmg*` scripts chain the build for you; a bare
> `electron-builder` call does not, and fails with
> _"entry file out/main.js does not exist"_.
The smoke test does not exercise the BrowserWindow (no display on headless CI). It spawns Electron, waits for the embedded server to answer `/api/health`, then shuts down. Anything that depends on the renderer is part of the manual QA checklist on the PR.
## Known caveats
- **Bundle size** ≈ 80 MB DMG, ≈ 250 MB on disk. The standard Electron tax. The Windows installer is comparable. Tauri would cut this dramatically but at the cost of a sidecar-process model and a Rust toolchain dependency — fair to revisit in a follow-up PR if bundle size becomes a real complaint.
- **Native modules**: `better-sqlite3` is rebuilt against Electron's Node version automatically via `electron-builder install-app-deps` in the desktop workspace's `postinstall`. On Windows it is fetched as a **prebuilt Electron binary**, so no Visual Studio C++ toolchain is needed in the common case. If that build *does* fail (or the binary is missing afterward), `npm run desktop:install` — and any `desktop:*` build — prints the exact per-OS fix (Windows: Visual Studio Build Tools with the "Desktop development with C++" workload; macOS: `xcode-select --install`; Linux: build-essential + python3) plus a no-toolchain alternative (`npm install --ignore-scripts``node node_modules/electron/install.js``npx electron-builder install-app-deps`), and exits non-zero — failing loudly at install/build time rather than crashing at runtime. Even so, if the module is unavailable the server falls back to `node:sqlite` (per #37), so the app still boots.
- **Per-architecture DMGs**: `npm run desktop:dmg` builds **both** macOS DMGs (one `arm64`, one `x64`) — the release build, and slower because it packages each architecture separately. It does **not** produce a merged universal binary; the release ships the two per-arch DMGs. `npm run desktop:dmg:arm64` and `npm run desktop:dmg:x64` build a single architecture instead — much faster, and roughly half the disk. If you specifically want a **single merged universal binary** (both slices in one `.dmg`, `lipo`-fat), `npm run desktop:dmg:universal` produces one via `@electron/universal` — the slowest option, and not what the release ships, but handy for hand-distributing one file that runs on any Mac.
- **Auto-update**: not wired on either platform. The current update path is *re-download the latest installer* (DMG on macOS, `.exe` on Windows). `electron-updater` + GitHub Releases is the natural follow-up.
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "Apple could not verify…" on first launch (macOS) | Unnotarized DMG | `xattr -cr ~/Downloads/ClaudeCodeMonitor-*.dmg` |
| "Windows protected your PC" on first launch (Windows) | The `.exe` is unsigned by default (SmartScreen) | Click **More info → Run anyway**. To remove the prompt for everyone, the maintainer can enable Authenticode signing via `CSC_LINK` + `CSC_KEY_PASSWORD` |
| macOS prompts to install Rosetta when opening the app | You installed the **x64** build on an Apple Silicon Mac | Check your arch with `uname -m` (`arm64` → Apple Silicon, build with `desktop:dmg:arm64`). The arch-specific `desktop:dmg:arm64` / `desktop:dmg:x64` builds each wipe `release/` and emit a single DMG whose mounted-volume title states the architecture — e.g. *Claude Code Monitor (Apple Silicon)* — so there is no ambiguous window to drag from. (`desktop:dmg` emits both per-arch DMGs at once, for release.) If stale DMGs from an older build linger, clear them with `rm -rf desktop/release` and rebuild |
| Window shows but content is blank (macOS) | Server didn't boot — check `~/Library/Logs/Claude Code Monitor/desktop.log` | Restart from tray → *Restart Server* |
| Window shows but content is blank (Windows) | Server didn't boot — check `%APPDATA%\Claude Code Monitor\logs\desktop.log` | Restart from tray → *Restart Server* |
| Tray icon missing (macOS) | The OS hides tray icons when the menu bar is full | Move other menu-bar items aside, or look in the overflow chevron |
| Tray icon missing (Windows) | Windows tucked it into the notification-area overflow | Click the **^** overflow chevron in the taskbar; drag the icon out to keep it pinned |
| App didn't auto-start at login (macOS) | Login Items entry got revoked by macOS | Toggle *Open at Login* off and on again from the tray menu |
| App didn't auto-start at login (Windows) | The `HKCU\…\Run` startup entry is missing or was disabled | Toggle *Open at Login* off and on again from the tray menu, then confirm the entry under *Task Manager → Startup* is **Enabled** |
| `npm run desktop:win` / `:win:portable` fails or produces nothing | electron-builder packages for the host OS — you ran it on macOS/Linux | Build the Windows `.exe` **on Windows** (and DMGs on macOS) |
| Desktop build/install fails on `better-sqlite3` / native binary missing | No C++ toolchain, or no prebuilt for your Node version | Run `npm run desktop:install` and follow the printed help, or use the no-toolchain alternative (`npm install --ignore-scripts``node node_modules/electron/install.js``npx electron-builder install-app-deps`); or use Node LTS 20/22 |
| Port 4820 already in use, app refuses to start | Something other than the dashboard is on 4820 and it doesn't answer `/api/health` | The app will pick a fallback (48214829, then a random high port) — check the tray menu's port indicator |
| Dashboard stays empty — 0 sessions, 0 agents, no real-time updates | The app bound a fallback port (4820 was taken), and the Claude Code hooks were posting events to the wrong port | Fixed — the server publishes its live port to `~/.claude/.agent-dashboard.json` and the hook handler reads it. After upgrading from a pre-fix build, **start a new Claude Code session** so the updated hooks take effect |
| `desktop:dmg` seems slow | Not stuck — it packages two architectures back-to-back (`arch=x64` then `arch=arm64`) | Wait it out, or build a single architecture with `desktop:dmg:arm64` / `desktop:dmg:x64` |
| Build fails: `entry file out/main.js does not exist` | `electron-builder` was run without compiling TypeScript first | Build via `npm run desktop:dmg*` (chains the build); don't invoke `electron-builder` bare |
| Signing fails with `Application … could not be found` | A code-signing certificate in your keychain was auto-discovered | Fixed — the `package` script sets `CSC_IDENTITY_AUTO_DISCOVERY=false`; build via `npm run desktop:dmg*` |
| "Run Claude" reports the `claude` CLI isn't on your PATH | A Finder/Dock-launched app inherits launchd's minimal PATH, not your shell PATH | Fixed — the app recovers your login-shell PATH at startup. If it persists, ensure `claude` is a real executable (not a shell alias/function) and on your shell PATH |
| Imported history / sessions vanished after updating the app | Older builds stored the database inside the (replaceable) app bundle | Fixed — data now lives in `~/Library/Application Support/Claude Code Monitor/data/` and survives reinstalls. After upgrading from a pre-fix build, re-run **Import History → Rescan** once |
| Signing fails: `Application … could not be found` after retries | A keychain code-signing certificate was auto-discovered | Fixed — the `package` script sets `CSC_IDENTITY_AUTO_DISCOVERY=false`; build via `npm run desktop:dmg*` |
+4
View File
@@ -35,6 +35,10 @@ RUN npm run build
FROM node:22-alpine
WORKDIR /app
# Runs Claude Code sessions inside a named tmux session per lane so both the
# dashboard (via node-pty attach) and a real terminal can share one live pane.
RUN apk add --no-cache tmux
COPY --from=server-deps /app/node_modules ./node_modules/
COPY package.json ./
COPY server/ ./server/
+22 -140
View File
@@ -1,6 +1,27 @@
# Installation
A step-by-step guide to get the Claude Code Agent Monitor up and running on your machine, with optional sections for importing history, running in a container, and using the native desktop app (macOS & Windows).
A step-by-step guide to get the Claude Code Agent Monitor up and running on your machine, with optional sections for importing history and running in a container.
## Fastest path — install it as a Claude Code plugin
If all you want is a working dashboard, skip every step below. From Claude Code:
```
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
/plugin install ccam@claude-code-agent-monitor-plugins
```
The next session start installs the hooks, boots the server, puts the `ccam` CLI
on PATH and connects the MCP tools — no clone, no `npm run setup`, no
`npm run install-hooks`, no manual `npm start`. Run `/ccam-doctor` to check the
result and `/ccam-open` to build the UI and get the URL.
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
the server stores data through `node:sqlite`). Full behavior, including the
uninstall cleanup, is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
Follow the steps below instead when you want a checkout — to develop the
dashboard, run it from source, or stay on Node 20.
## Requirements
@@ -168,122 +189,6 @@ Open **http://localhost:4820** in your browser.
---
## Desktop App (macOS & Windows) (optional)
If you'd rather not keep a terminal window open, the project also ships an Electron 35-based **native desktop app** (the `desktop/` workspace), available for both **macOS** and **Windows**. It embeds the Express server in-process, renders the built React client in a `BrowserWindow`, registers a menu-bar / notification-area (tray) icon, and offers a one-click "Open at Login" toggle. Everything you'd see in the browser at `localhost:4820` lives inside a single app you install once — distributed as a macOS `.app` (in a `.dmg`) and a Windows `.exe` (an NSIS installer plus a no-install portable build).
### Prerequisites
| For… | You need |
|---|---|
| Downloading a pre-built installer (macOS) | macOS — nothing else |
| Downloading a pre-built installer (Windows) | Windows 10/11 (x64) — nothing else |
| Building the DMG locally (macOS) | macOS, Node.js 20+ (22+ recommended), npm 9+, and **Xcode command-line tools** (`xcode-select --install`) so the native `better-sqlite3` module can be rebuilt for Electron's ABI |
| Building the `.exe` locally (Windows) | Windows, Node.js 20+ (22+ recommended), npm 9+. `better-sqlite3` is fetched as a **prebuilt Electron binary** by `npm run desktop:install`, so no Visual Studio C++ toolchain is needed in the common case. If the build _does_ fail, `npm run desktop:install` prints the exact fix (Visual Studio Build Tools + "Desktop development with C++") plus a no-toolchain alternative and exits non-zero rather than failing silently |
### Way 1 — Download a pre-built installer
The fastest path. There are two flavours:
**1a. From the latest GitHub Release** *(recommended — public, no sign-in)*
Open [**Releases → latest**](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) and download the asset for your platform. CI publishes a new `vX.Y.Z` release automatically every time the version in `package.json` is bumped on `master`, so this link always points at the current shipping build.
| Platform | Asset | Notes |
|---|---|---|
| macOS (Apple Silicon) | `ClaudeCodeMonitor-<ver>-arm64.dmg` | drag into `/Applications` |
| macOS (Intel) | `ClaudeCodeMonitor-<ver>-x64.dmg` | drag into `/Applications` |
| Windows (installer) | `ClaudeCodeMonitor-Setup-<ver>-x64.exe` | per-user install, no admin |
| Windows (portable) | `ClaudeCodeMonitor-<ver>-x64-portable.exe` | run without installing |
**1b. From the per-commit CI artifact** *(useful for testing master before it's tagged — sign-in required, 14-day retention)*
Every green run of the desktop CI jobs uploads a packaged artifact — `ClaudeCodeMonitor-dmg` from the `🍎 macOS Desktop (DMG)` job and `ClaudeCodeMonitor-win` from the `🪟 Windows Desktop (EXE)` job:
- **Via the GitHub UI:** open the latest passing run under [Actions](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/actions/workflows/ci.yml?query=branch%3Amaster+is%3Asuccess), scroll to **Artifacts**, and download `ClaudeCodeMonitor-dmg` (macOS) or `ClaudeCodeMonitor-win` (Windows).
- **Via the `gh` CLI:**
```bash
gh run download <run-id> -R Smartgift-AI/Claude-Code-Monitor -n ClaudeCodeMonitor-dmg # macOS
gh run download <run-id> -R Smartgift-AI/Claude-Code-Monitor -n ClaudeCodeMonitor-win # Windows
```
Unzip the macOS artifact to get the `.dmg`s, or the Windows artifact to get the NSIS installer + portable `.exe`s.
Then jump to [Install the app](#install-the-app).
### Way 2 — Build the installer locally
From the project root, after `git clone`. electron-builder packages for the **host OS**, so build the macOS DMG on a Mac and the Windows `.exe` on Windows. The common prelude is the same:
```bash
npm run setup # install root + client + vscode-extension deps
npm run build # build the React client (the SPA the window loads)
npm run desktop:install # install Electron + electron-builder into desktop/
# macOS (run on macOS):
npm run desktop:dmg:arm64 # fast single-arch DMG → desktop/release/
# Windows (run on Windows):
npm run desktop:win # NSIS installer .exe → desktop/release/
```
The artifact lands in `desktop/release/`. Pick the build command that matches your goal:
| Command | Platform / Architecture | Speed | Use when |
|---|---|---|---|
| `npm run desktop:dmg` | macOS — both per-arch DMGs (arm64 + x64) | **Slower** | Building the release DMGs for everyone |
| `npm run desktop:dmg:arm64` | macOS — Apple Silicon only | Fast (~1 min) | Building for your own Apple Silicon Mac |
| `npm run desktop:dmg:x64` | macOS — Intel only | Fast (~1 min) | Building for your own Intel Mac |
| `npm run desktop:dmg:universal` | macOS — one merged universal DMG (arm64 + x86_64) | **Slowest** | Hand-distributing a single file that runs on any Mac (not what the release ships) |
| `npm run desktop:win` | Windows — NSIS installer `.exe` (x64) | — | Building the per-user installer |
| `npm run desktop:win:portable` | Windows — portable `.exe` (x64) | — | Building the no-install portable build |
| `npm run desktop:install` | — | — | Install Electron + electron-builder deps; preflights the native `better-sqlite3` build and prints actionable setup help on failure |
| `npm run desktop:build` | — | — | TypeScript compile only (`out/`) |
| `npm run desktop:dev` | — | — | Build, then launch Electron locally |
| `npm run desktop:test` | — | — | Smoke test (spawn Electron, probe `/api/health`) |
> [!IMPORTANT]
> **DMGs build on macOS; Windows `.exe`s build on Windows** — electron-builder packages for the host OS. On macOS, `npm run desktop:dmg` builds the app **twice** (one tree per architecture) and emits **both** per-arch DMGs (`arm64` + `x64`) — the release build. It does **not** merge them into a single universal binary; the two DMGs are what ship. **When building for your own Mac, use `desktop:dmg:arm64` or `desktop:dmg:x64`** — a single architecture finishes in roughly a minute. CI already builds both DMGs and the Windows `.exe`s for you (see Way 1).
### Install the app
**macOS.** Each `desktop:dmg*` build wipes `release/` first. `desktop:dmg:arm64`
`…-arm64.dmg` and `desktop:dmg:x64``…-x64.dmg` each emit a single DMG whose
mounted-volume title states the architecture (e.g. *Claude Code Monitor (Apple
Silicon)*); `desktop:dmg` emits **both** (`…-arm64.dmg` + `…-x64.dmg`) for
release. Install the one matching your Mac: an x64 build on Apple Silicon makes
macOS prompt for Rosetta.
```bash
open desktop/release/ClaudeCodeMonitor-*-arm64.dmg # the arch you built
```
1. The DMG mounts — drag **Claude Code Monitor** into your `Applications` folder.
2. The DMG is ad-hoc signed, so macOS Gatekeeper shows a warning (*"Apple could not verify…"*) on first launch. Strip the quarantine attribute, then open it:
```bash
xattr -cr "/Applications/Claude Code Monitor.app"
open "/Applications/Claude Code Monitor.app"
```
Alternatively, open → *System Settings → Privacy & Security* and click *Open Anyway*.
**Windows.**
1. Run `ClaudeCodeMonitor-Setup-<ver>-x64.exe`. It installs **per-user** (no administrator elevation) and lets you pick the install directory — or run the `*-portable.exe` to launch without installing.
2. The installer is **unsigned** by default, so Windows **SmartScreen** may show *"Windows protected your PC"* on first launch — click **More info → Run anyway**.
3. Launch from the Start menu / desktop shortcut.
Once running, the embedded server boots on port `4820` (or adopts an already-healthy server on `4820`, or falls back to `4821``4829` / a random high port), the menu-bar / notification-area (tray) icon appears, and the dashboard window opens. **Hooks are installed automatically on first boot** — an install-only user does not need `npm run install-hooks`; just start a new Claude Code session. Closing the window hides it but keeps the server running; **Quit** from the tray exits.
> [!NOTE]
> The packaged app stores its SQLite database and VAPID keys in a per-user app-data directory **outside** the app bundle / install dir — `~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows. Your imported history and events therefore **survive app reinstalls and updates** (the Windows NSIS uninstaller keeps this data by default). (Older macOS builds kept the database inside the bundle, which is read-only once installed and code-signed — that broke History Import; it is now fixed. If you are upgrading from a pre-fix build, there is a one-time data gap: re-run **Settings → Import History → Rescan** once.)
Full user guide: [`DESKTOP.md`](DESKTOP.md). Contributor / architecture reference: [`desktop/README.md`](desktop/README.md). Desktop-specific setup details (logs, auto-start, port adoption) are in [SETUP.md → Desktop App Setup](./SETUP.md#desktop-app-setup).
---
## Optional: Local MCP server
If you want AI agents to call dashboard functionality through MCP tools, run the local MCP server in `mcp/`:
@@ -448,17 +353,6 @@ If you see an error box at startup saying *"SQLite backend not available"*, eith
Then run: `npm rebuild better-sqlite3`
### Desktop build or install fails on the native dependency
Unlike the root server (which falls back to `node:sqlite`), the desktop app **requires** `better-sqlite3` built for Electron's ABI. If that build can't happen, `npm run desktop:install` (and the desktop `prebuild` gate that runs before every `desktop:*` build) now stops with copy-pasteable setup help instead of a raw node-gyp trace or a runtime crash: it lists the per-OS C++ toolchain prerequisites (Windows: Visual Studio Build Tools + "Desktop development with C++"; macOS: `xcode-select --install`; Linux: build-essential + python3), notes that Node LTS 20/22 ship prebuilt binaries, and offers a no-toolchain alternative:
```bash
cd desktop
npm install --ignore-scripts
node node_modules/electron/install.js
npx electron-builder install-app-deps
```
### `npm run dev` fails immediately
Ensure both server and client dependencies are installed:
@@ -482,18 +376,6 @@ The Vite dev server and Express server run on different ports. Make sure both ar
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
### Desktop App (macOS & Windows) issues
| Symptom | Cause | Fix |
|---|---|---|
| *"Apple could not verify…"* on first launch (macOS) | The DMG is ad-hoc signed (no paid Apple Developer ID) | `xattr -cr "/Applications/Claude Code Monitor.app"`, then open it — or use *System Settings → Privacy & Security → Open Anyway* |
| *"Windows protected your PC"* on first launch (Windows) | The `.exe` is unsigned by default, so SmartScreen prompts | Click **More info → Run anyway** |
| `npm run desktop:dmg` seems slow (macOS) | Not hung — it packages two architectures back-to-back (`arch=x64` then `arch=arm64`) | Wait it out, or use `npm run desktop:dmg:arm64` / `npm run desktop:dmg:x64` for a fast single-arch build |
| `entry file out/main.js does not exist` | `npm run clean` (in `desktop/`) deleted `out/`; `electron-builder` only packages, it does not compile | Re-run `npm run desktop:build` (or just use a `desktop:dmg*` / `desktop:win*` script, which chains the build) |
| Desktop window opens but is blank | The embedded server failed `/api/health` within 30 s | Check the desktop log (`~/Library/Logs/Claude Code Monitor/desktop.log` on macOS, `%APPDATA%\Claude Code Monitor\logs\desktop.log` on Windows), then tray → *Restart Server* |
| "Run Claude" says `claude` is not on your PATH | A Finder/Dock-launched macOS app only inherits launchd's minimal PATH, not your login-shell PATH (on Windows the process already inherits the user PATH) | The app recovers your login-shell PATH at startup so it can find and spawn the `claude` CLI. If it still fails, make sure `claude` is a real executable on your shell PATH — not a shell alias or function |
| Imported history vanished after updating the app | Older builds stored the database inside the (replaceable) `.app` bundle | Fixed — data now lives in the per-user app-data dir (`~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows) and survives reinstalls/updates. After upgrading from a pre-fix build, re-run **Settings → Import History → Rescan** once |
---
## Ports
+57 -9
View File
@@ -6,6 +6,8 @@ tool call to an Express + SQLite server, a React UI updates over WebSocket, and
Internal build — all rights reserved.
*(Tiếng Việt: [README.vi.md](README.vi.md))*
## What it does
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
@@ -15,8 +17,10 @@ Internal build — all rights reserved.
- **Stage detection.** The stage is inferred from the tool stream, so a session
that never calls `ccam stage` still shows progress — rendered dashed amber and
never as done, because an inference is not evidence.
- **Run Claude from the browser.** Spawn a session in a lane's directory, stream
its output, send follow-ups, resume any past session.
- **Run Claude from the browser.** A real terminal (tmux + a real PTY,
rendered with xterm.js) attached to a lane's directory — the exact TUI you'd
see locally, fully interactive, resumable, and attachable from a real
terminal too via `ccam lanes shell`.
- **Analytics, alerts, Kanban and a workflow view**, plus an MCP server and a CLI.
## Requirements
@@ -26,7 +30,25 @@ are verified on — node 25 currently breaks 6 server tests through a
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
change.
## Install and run
## Install as a Claude Code plugin
Two commands on a machine that has nothing but Claude Code, no clone and no
`npm run setup`:
```
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
/plugin install ccam@claude-code-agent-monitor-plugins
```
The first session start installs the hooks, boots the server, puts `ccam` on
PATH and connects the MCP tools; it runs detached, so the session never waits on
it. `/ccam-doctor` reports the state, `/ccam-open` builds the UI and prints the
URL, `/ccam-update` refreshes after a plugin update. This path needs Node
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
Details, including what to delete on uninstall:
[`docs/PLUGINS.md`](docs/PLUGINS.md).
## Install from a checkout
```bash
npm run setup # root, client and vscode-extension dependencies
@@ -42,8 +64,11 @@ Development, with hot reload:
npm run dev # server on :4820, Vite client on :5173
```
`DASHBOARD_PORT` overrides the port. `postinstall` writes the Claude Code hook
entries that feed the dashboard.
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
built UI is served from (defaults to `client/dist`; the plugin install points it
at its own runtime directory). `postinstall` writes the Claude Code hook entries
that feed the dashboard — do not run it when the `ccam` plugin is installed, or
every event is counted twice.
## The CLI
@@ -54,6 +79,7 @@ ccam status # is the dashboard up
ccam start # start it in the background and wait for healthy
ccam sessions # recent sessions
ccam lanes # lanes with stage and progress
ccam lanes pipeline # this lane's pipeline template, or switch it
ccam stage <name> # declare the current lane's stage
ccam tail # live event feed
```
@@ -80,8 +106,31 @@ events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
move backwards between work sessions. Detection never writes the declared stage,
and an inferred node never renders as done.
A lane's pipeline template can be switched after creation — `ccam lanes
pipeline <template>` from the terminal, or the pipeline-template picker next to
the lane's title in the Workspace detail panel. Both re-resolve the lane's
current declared stage against the new template's nodes and warn if it no
longer matches one.
A lane can also run **its own application stack**, isolated per lane, when its
repository declares a profile at `<repo>/.ccam/profile/` — a `profile.env` of
declarations plus shell hooks the dashboard calls. Each lane gets a slot, and its
ports and per-lane directories derive from it:
```bash
ccam lanes up # boot the stack of the lane owning this directory
ccam lanes runtime # slot, ports, service health
ccam lanes logs api # tail a service log
ccam lanes down
```
Services are fully detached, so restarting the dashboard never stops a running
lane. This is resource namespacing on the host, not a container: lanes run as the
same user and share the network.
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
preflight contract, the Workspace page, and `GET /api/lanes/:id/git`.
preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full
runtime/profile contract.
## Tests
@@ -100,8 +149,7 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
| `client/` | React 18 + Vite + Tailwind dashboard |
| `bin/ccam.js` | CLI |
| `mcp/` | MCP server exposing read-only dashboard tools |
| `desktop/` | Electron wrapper that embeds the server |
| `docs/` | Architecture, API, lanes, database, deployment |
| `docs/` | Architecture, API, lanes, database |
| `plugins/` | Claude Code plugins shipped with the dashboard |
## Docs
@@ -110,5 +158,5 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
- [`docs/LANES.md`](docs/LANES.md) — lanes, pipelines, stage detection
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
- [`INSTALL.md`](INSTALL.md) · [`DEPLOYMENT.md`](DEPLOYMENT.md) · [`DESKTOP.md`](DESKTOP.md)
- [`INSTALL.md`](INSTALL.md)
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
+161
View File
@@ -0,0 +1,161 @@
# Claude Code Monitor
Bản build nội bộ SmartGift. Dashboard local-first cho Claude Code: hooks POST mỗi
tool call lên server Express + SQLite, React UI cập nhật qua WebSocket, và
**lane** (làn đường) theo dõi công việc song song của agent qua một pipeline.
Internal build — all rights reserved.
*(Bản dịch tiếng Việt của [README.md](README.md), tham khảo — README.md gốc là bản chính thức.)*
## Làm được gì
- **Session, agent, event.** Mọi thứ Claude Code emit ra, ghi lại và tìm kiếm
được: tool call, token usage, cost, cây subagent, transcript.
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Lane
đi qua các pipeline stage và dashboard hiển thị nó đang ở đâu.
- **Stage detection.** Stage được suy luận (infer) từ tool stream, nên một
session không bao giờ gọi `ccam stage` vẫn hiển thị progress — render dạng
viền chấm màu hổ phách (amber) và không bao giờ hiện `done`, vì suy luận
không phải bằng chứng.
- **Chạy Claude từ trình duyệt.** Spawn session trong thư mục của lane, stream
output, gửi follow-up, resume session cũ bất kỳ.
- **Analytics, alert, Kanban và workflow view**, cộng thêm MCP server và CLI.
## Yêu cầu
Node **>= 20** (`engines` trong `package.json`). Node **24** là bản test suite
được verify — node 25 hiện đang làm gãy 6 test server do lệch ABI
`better-sqlite3` và 20 test client do thay đổi global `localStorage`.
## Cài như Claude Code plugin
Hai lệnh, trên máy chỉ có Claude Code, không cần clone repo, không cần
`npm run setup`:
```
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
/plugin install ccam@claude-code-agent-monitor-plugins
```
Lần session-start đầu tiên sẽ cài hooks, boot server, đưa `ccam` vào PATH và
kết nối MCP tools; chạy detached nên session không phải chờ. `/ccam-doctor`
báo trạng thái, `/ccam-open` build UI và in URL, `/ccam-update` refresh sau
khi plugin update. Đường này cần Node **>= 22.5** (không dùng
`better-sqlite3` native, server dùng `node:sqlite`). Chi tiết, kể cả những gì
cần xóa lúc uninstall: [`docs/PLUGINS.md`](docs/PLUGINS.md).
## Cài từ checkout
```bash
npm run setup # cài dependency cho root, client và vscode-extension
npm run build # build client vào client/dist
npm start # serve client đã build + API trên :4820
```
Mở <http://localhost:4820>.
Development, có hot reload:
```bash
npm run dev # server trên :4820, Vite client trên :5173
```
`DASHBOARD_PORT` override port, `DASHBOARD_CLIENT_DIST` override nơi UI đã
build được serve (mặc định `client/dist`; bản cài qua plugin trỏ vào runtime
directory riêng của nó). `postinstall` ghi các hook entry Claude Code để nạp
dữ liệu cho dashboard — đừng chạy nó khi plugin `ccam` đã cài, không thì mỗi
event bị đếm hai lần.
## CLI
`ccam` được link sẵn bởi `npm run setup`; không thì gọi `node bin/ccam.js`.
```bash
ccam status # dashboard có đang chạy không
ccam start # start ngầm (background) và chờ tới khi healthy
ccam sessions # session gần đây
ccam lanes # danh sách lane kèm stage và progress
ccam lanes pipeline # pipeline template của lane này, hoặc đổi nó
ccam stage <name> # khai báo stage hiện tại của lane
ccam tail # xem live event feed
```
`ccam --help` liệt kê phần còn lại.
## Lane
Lane là một working directory mà dashboard theo dõi. Hai loại:
- **adopted** (nhận nuôi) — một thư mục bạn đã có sẵn. Dashboard chỉ đọc nó;
không bao giờ reset hay xóa.
- **managed** (tự quản lý) — git worktree do dashboard tạo dưới `LANES_ROOT`.
Dashboard sở hữu toàn bộ vòng đời và có thể reset/xóa nó, phía sau một guard
ba lớp kiểm tra khi hủy (destroy guard) và một bước preflight đếm số mà bên
gọi phải echo lại.
```bash
ccam lanes add --cwd /path/to/repo --title "My feature" # adopt
ccam lanes add --repo /path/to/repo --slug my-feature # managed worktree
```
Stage khai báo (declared) đến từ `ccam stage`. Stage suy luận (inferred) đến
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên lane có
thể lùi lại giữa các work session. Detection không bao giờ ghi đè stage khai
báo, và node suy luận không bao giờ render thành `done`.
Pipeline template của lane có thể đổi sau khi tạo — `ccam lanes
pipeline <template>` từ terminal, hoặc dùng picker chọn pipeline-template
cạnh tiêu đề lane trong panel chi tiết của trang Workspace. Cả hai đều
re-resolve lại stage đã khai báo hiện tại của lane theo node-list của template
mới, và cảnh báo nếu nó không còn khớp node nào.
Lane cũng có thể chạy **application stack riêng của nó**, cô lập theo từng
lane, khi repository của nó khai báo một profile tại `<repo>/.ccam/profile/`
— một file `profile.env` khai báo cộng shell hooks mà dashboard gọi. Mỗi lane
được cấp một slot, port và thư mục riêng theo lane được suy ra từ đó:
```bash
ccam lanes up # boot stack của lane sở hữu thư mục hiện tại
ccam lanes runtime # slot, port, tình trạng service
ccam lanes logs api # tail log của một service
ccam lanes down
```
Service chạy hoàn toàn detached, nên restart dashboard không bao giờ dừng
lane đang chạy. Đây là namespacing tài nguyên trên host, không phải
container: các lane chạy chung user và share network.
[`docs/LANES.md`](docs/LANES.md) có mô hình pipeline, destroy guard, hợp đồng
(contract) preflight, trang Workspace, `GET /api/lanes/:id/git`, và toàn bộ
hợp đồng runtime/profile.
## Test
```bash
npm run test:server # node:test
npm run test:client # Vitest
```
Cả hai phải xanh (pass) trước khi commit; pre-commit hook chạy chúng cộng
thêm Prettier.
## Cấu trúc thư mục
| Đường dẫn | Là gì |
|---|---|
| `server/` | Express API, schema SQLite, hook ingest, thư viện lane và worktree |
| `client/` | Dashboard React 18 + Vite + Tailwind |
| `bin/ccam.js` | CLI |
| `mcp/` | MCP server expose các tool đọc dữ liệu dashboard (read-only) |
| `docs/` | Architecture, API, lanes, database |
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
## Tài liệu
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — luồng request, schema, bề mặt WebSocket
- [`docs/LANES.md`](docs/LANES.md) — lane, pipeline, stage detection
- [`docs/API.md`](docs/API.md) — REST endpoint (`openapi.yaml` được generate tự động)
- [`docs/DATABASE.md`](docs/DATABASE.md) — bảng và migration
- [`INSTALL.md`](INSTALL.md)
- [`CLAUDE.md`](CLAUDE.md) — quy tắc agent làm việc trong repo này phải tuân theo
+2 -99
View File
@@ -98,6 +98,7 @@ Container-specific behavior:
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
| `NODE_ENV` | `development` | Set to `production` to serve built client |
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `ccam` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
| `CCAM_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
| `CCAM_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
| `CCAM_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
@@ -117,7 +118,7 @@ DASHBOARD_PORT=9000 npm run dev
> [!NOTE]
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `48204859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agent-dashboard.json` so the Claude Code hook handler discovers it without any env var.
>
> Multiple dashboards can run side by side — for example `npm run dev` and the desktop app (macOS or Windows) at the same time. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
>
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
>
@@ -216,59 +217,6 @@ The dashboard, landing page, and wiki each ship as independent Progressive Web A
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
### Desktop App Setup
The `desktop/` workspace ships the dashboard as a **native desktop app** for both **macOS** (a `.app` distributed as a `.dmg`) and **Windows** (an `.exe` — an NSIS installer plus a no-install portable build), built with Electron 35. It is an Electron shell that **embeds the existing Express server in-process** — it does not reimplement anything. For installation (download a pre-built installer from the [latest GitHub Release](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) or the per-commit `ClaudeCodeMonitor-dmg` / `ClaudeCodeMonitor-win` CI artifact, or build one locally — then on macOS mount, drag, Gatekeeper bypass; on Windows run the installer / portable, SmartScreen bypass), see [INSTALL.md → Desktop App (macOS & Windows)](./INSTALL.md#desktop-app-macos--windows-optional). The full user guide is [`DESKTOP.md`](./DESKTOP.md); the contributor / architecture reference is [`desktop/README.md`](./desktop/README.md).
This section covers the parts of running the desktop app that matter for setup.
**Building and running.** All commands run from the repo root. electron-builder packages for the **host OS** — build the macOS DMG on a Mac (`desktop:dmg*`) and the Windows `.exe` on Windows (`desktop:win*`):
| Script | Command | Description |
|---|---|---|
| `desktop:install` | `npm run desktop:install` | Install Electron + electron-builder into `desktop/`; fetches `better-sqlite3` as a prebuilt Electron binary for Electron's ABI (no Visual Studio C++ toolchain needed in the common case; on macOS, Xcode CLI tools cover any fallback build). Preflights the native `better-sqlite3` build; on failure prints actionable per-OS setup help plus a no-toolchain alternative and exits non-zero (also enforced by the desktop `prebuild` gate) |
| `desktop:build` | `npm run desktop:build` | Prebuild guard + `tsc``desktop/out/` |
| `desktop:dev` | `npm run desktop:dev` | Build, then launch Electron against `out/main.js` |
| `desktop:test` | `npm run desktop:test` | Build, then run the smoke test (spawn Electron, probe `/api/health`) |
| `desktop:dmg` | `npm run desktop:dmg` | **macOS****both** per-arch DMGs (arm64 + x64) → `desktop/release/`. Correct for release. **Slower** (packages each arch). |
| `desktop:dmg:arm64` | `npm run desktop:dmg:arm64` | **macOS** — Apple-Silicon-only DMG → `desktop/release/`. **Fast (~1 min).** |
| `desktop:dmg:x64` | `npm run desktop:dmg:x64` | **macOS** — Intel-only DMG → `desktop/release/`. **Fast (~1 min).** |
| `desktop:win` | `npm run desktop:win` | **Windows** — NSIS installer `.exe` (x64) → `desktop/release/`. |
| `desktop:win:portable` | `npm run desktop:win:portable` | **Windows** — no-install portable `.exe` (x64) → `desktop/release/`. |
> [!NOTE]
> Every `desktop:dmg*` / `desktop:win*` script chains `npm run build` first. Running `electron-builder` bare skips the TypeScript compile and fails with `entry file out/main.js does not exist`. `npm run clean` inside `desktop/` deletes `out/` and `release/` — after a clean you must `npm run desktop:build` again before packaging.
> [!TIP]
> On macOS, building a DMG rebuilds the native `better-sqlite3` module for the **target** architecture, which can leave it built for the wrong CPU arch for your local machine. The desktop `prebuild` step auto-heals this — it rebuilds `better-sqlite3` for the local machine on the next `desktop:build` — so `npm run desktop:dev` and `npm run desktop:test` keep working after a cross-arch DMG build with no manual `npm run desktop:install` needed.
**Hooks are auto-installed by the app.** On its first **owned-server** boot the desktop app writes the Claude Code hook configuration to `~/.claude/settings.json` itself, then starts the background services (update scheduler, `cc-watcher` config watcher, orphaned-run reconciliation) — the same `startBackgroundServices()` that `node server/index.js` runs. An install-only user (macOS or Windows) therefore never needs `npm run install-hooks` from a checkout: just **start a new Claude Code session** after the app is running. (If the app *adopts* an existing server instead of starting its own, that server already did its own hook bootstrap — see port adoption below.)
**Port-adoption behavior.** When the desktop app launches, its embedded server picks a port:
1. It prefers **`4820`**.
2. If a healthy dashboard server already answers `GET /api/health` on `4820` (for example you ran `npm start` in a terminal), the app **adopts that server** instead of double-binding — no SQLite contention. An adopted server is *not* owned by the app, so quitting the app leaves it running.
3. Otherwise it falls back to `4821``4829`, then to a random high port (`49152``49500`).
The chosen port is shown in the tray menu. The embedded server also honors the dashboard env vars in [Environment variables](#environment-variables) (`DASHBOARD_PORT` is set automatically by the desktop host).
**Data directory.** The packaged app stores its SQLite database and VAPID keys in a per-user app-data directory — `~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows — **outside** the app bundle / install dir. The desktop host sets `DASHBOARD_DATA_DIR` to this per-user location automatically. Keeping writable state out of the bundle means a packaged, code-signed (and therefore read-only) `.app` never tries to write inside itself, and your imported history and events **survive app reinstalls and updates** (the Windows NSIS uninstaller keeps this data by default). (Older macOS builds kept the database inside the bundle, which broke History Import; after upgrading from a pre-fix build, re-run **Settings → Import History → Rescan** once to close the one-time data gap.)
**`claude` CLI resolution.** A Finder/Dock-launched macOS app inherits only launchd's minimal `PATH`, not your login-shell `PATH`. So the app can find and spawn the `claude` CLI for the "Run Claude" feature, the desktop host recovers your login-shell `PATH` at startup. (On Windows the process already inherits the user `PATH`, so no recovery is needed.) If "Run Claude" still reports that `claude` is not on `PATH`, make sure `claude` is a real executable on your shell `PATH` — a shell alias or function cannot be spawned.
**Auto-start at login.** Toggle *Open at Login* from the tray menu or the application menu. On macOS it registers via the first-party `SMAppService` API (Electron's `app.setLoginItemSettings`), so the entry appears under → *System Settings → General → Login Items*. On Windows it writes a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry, visible in *Task Manager → Startup*. When the app is launched at login, it starts **tray-only** — the dashboard window stays hidden until you click the tray icon.
**Logs.** The Electron main process has no terminal when launched from Finder / the Start menu, so it writes to a per-user log file:
```
~/Library/Logs/Claude Code Monitor/desktop.log # macOS
%APPDATA%\Claude Code Monitor\logs\desktop.log # Windows
```
Open it from the tray menu → **Show Logs**. Set `CCAM_DESKTOP_VERBOSE=1` to also mirror `info`/`warn` lines to stdout when running via `npm run desktop:dev`.
**Lifecycle reminder.** Closing the dashboard window only **hides** it — the server and tray keep running. **Quit** (⌘Q or tray → *Quit*) shuts the embedded server down gracefully and exits. Double-launching just focuses the existing window (single-instance lock); it never starts a second server.
---
## Database
@@ -606,48 +554,3 @@ If the build fails in Stage 1 with `better-sqlite3` errors, this is expected and
- Ensure you are using the latest Dockerfile (it should use `node:22-alpine` and **not** install `python3`, `make`, or `g++`)
- Run `docker build --no-cache -t agent-monitor .` to force a clean rebuild
- Check that `package.json` has `better-sqlite3` under `optionalDependencies`, not `dependencies`
---
### macOS desktop app — `npm run desktop:dmg` is slow
This is expected. `desktop:dmg` compiles, packages, and ad-hoc-signs the app **twice** — once for `arm64`, once for `x64` — and emits **both** per-arch DMGs (`…-arm64.dmg` + `…-x64.dmg`). It does not merge them into a single universal binary; the two per-arch DMGs are what ship. Packaging two architectures back-to-back is what takes the time; it is not hung.
For a build that targets your own Mac, use a single-arch command instead — it builds one architecture and finishes in roughly a minute:
```bash
npm run desktop:dmg:arm64 # Apple Silicon
npm run desktop:dmg:x64 # Intel
```
CI already produces both DMGs — pulled either from the [latest GitHub Release](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) (CI auto-publishes a `vX.Y.Z` when `package.json` is bumped on `master`) or from the per-commit `ClaudeCodeMonitor-dmg` workflow artifact — so you rarely need to build them locally.
---
### Desktop app — `entry file out/main.js does not exist`
You ran `electron-builder` without a TypeScript compile. `npm run clean` (in `desktop/`) deletes `out/`, and `electron-builder` only packages — it does not compile. Re-run `npm run desktop:build` first, or use a `desktop:dmg*` / `desktop:win*` script (each one chains `npm run build` for you). Never invoke `electron-builder` bare.
---
### macOS desktop app — Gatekeeper blocks the app on first launch
The DMG is **ad-hoc signed** by default (the project ships no paid Apple Developer ID), so macOS shows *"Apple could not verify…"* the first time you open the app. Strip the quarantine attribute:
```bash
xattr -cr "/Applications/Claude Code Monitor.app"
```
Or open → *System Settings → Privacy & Security* and click *Open Anyway*. Real Developer ID signing and notarization are opt-in via the `CSC_LINK` / `CSC_KEY_PASSWORD` and `APPLE_ID` / `APPLE_TEAM_ID` / `APPLE_APP_SPECIFIC_PASSWORD` repository secrets — see [`DESKTOP.md`](./DESKTOP.md#notarization-for-the-maintainer).
---
### Windows desktop app — SmartScreen blocks the app on first launch
The Windows `.exe` (NSIS installer and portable build) is **unsigned** by default, so Windows SmartScreen shows *"Windows protected your PC"* the first time you run it. Click **More info → Run anyway**. Authenticode signing is opt-in via the `CSC_LINK` / `CSC_KEY_PASSWORD` repository secrets — CI picks them up automatically when provided.
---
### Desktop app — no sessions appearing
The desktop app installs hooks on its **first owned-server boot**, not before. After the app is running, start a **new** Claude Code session and confirm `~/.claude/settings.json` contains entries referencing `hook-handler.js`. If the app adopted an existing server on `4820`, that server's own hook configuration applies instead. For a blank dashboard window, check the desktop log (`~/Library/Logs/Claude Code Monitor/desktop.log` on macOS, `%APPDATA%\Claude Code Monitor\logs\desktop.log` on Windows) via tray → *Show Logs* and use tray → *Restart Server*.
+870 -18
View File
@@ -190,8 +190,9 @@ async function api(method, pathname, body, options = {}) {
}
return data;
}
const get = (p) => api("GET", p);
const get = (p, b, options) => api("GET", p, undefined, options);
const post = (p, b, options) => api("POST", p, b, options);
const patch = (p, b, options) => api("PATCH", p, b, options);
/**
* Print the standard "server is not running" indicator and exit 1. Every
@@ -1520,6 +1521,133 @@ async function cmdLanesAdd(args) {
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
}
/**
* `ccam skills install` copy this checkout's `.claude/skills/ship-feature-lane/`
* into `~/.claude/skills/ship-feature-lane/`, so `/ship-feature-lane` is
* discoverable from a session running inside any lane's own working
* directory, not just inside this repo. Pure filesystem action; does not
* talk to the dashboard server at all. Reinstall, not merge overwrites an
* existing global copy with the current one.
*/
function cmdSkillsInstall() {
const { installShipFeatureLaneSkill } = require(
path.join(REPO_ROOT, "server", "lib", "skills-install.js")
);
try {
const result = installShipFeatureLaneSkill({ repoRoot: REPO_ROOT });
console.log(`${c.green("✔")} installed ship-feature-lane skill -> ${result.path}`);
} catch (err) {
console.error(`${err.message}`);
process.exitCode = 1;
}
}
/**
* `ccam lanes gc [--dry-run]` reap orphaned Playwright MCP processes
* (owning session died) and cap oversized hook logs, across every lane on
* this machine. Machine-wide, not one lane's; pure local process/file
* action, no HTTP round-trip.
*/
function cmdLanesGc(args) {
const dryRun = args.includes("--dry-run");
const laneGc = require(path.join(REPO_ROOT, "server", "lib", "lane-gc.js"));
const reaped = laneGc.reapOrphanMcp({ dryRun });
if (reaped.length) {
console.log(
`${dryRun ? "would reap" : "reaped"} ${reaped.length} orphaned MCP process(es): ${reaped.join(", ")}`
);
} else {
console.log("no orphaned MCP processes found");
}
const capped = laneGc.capOversizedLogs({ dryRun });
if (capped.length) {
for (const { path: logPath, sizeBefore } of capped) {
const mb = (sizeBefore / (1024 * 1024)).toFixed(1);
console.log(`${dryRun ? "would cap" : "capped"} ${logPath} (${mb}MB -> 2MB)`);
}
} else {
console.log("no oversized logs found");
}
}
/**
* `ccam lanes profile init <repo>` detect a Node.js project and scaffold
* `.ccam/profile/`. Pure filesystem action against the SOURCE repo; does not
* talk to the dashboard server at all.
*/
function cmdLanesProfileInit(args) {
const repo = args.find((arg) => !arg.startsWith("--"));
const force = args.includes("--force");
if (!repo) {
console.error("usage: ccam lanes profile init <repo> [--force]");
process.exitCode = 1;
return;
}
const resolved = path.resolve(repo);
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
console.error(`✖ not a directory: ${resolved}`);
process.exitCode = 1;
return;
}
const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js"));
const facts = laneDetect.detectNode(resolved);
if (!facts) {
console.error(
`✖ No detectable Node.js project at ${resolved} (looked for backend/package.json +\n` +
" frontend/package.json, or a root package.json).\n" +
" Auto-scaffolding currently supports Node.js repos in that layout only.\n" +
" Write .ccam/profile/ by hand — see docs/LANES.md."
);
process.exitCode = 1;
return;
}
let result;
try {
result = laneDetect.scaffoldProfile(resolved, facts, { force });
} catch (err) {
if (err.code === "EPROFILEEXISTS") {
console.error(`${err.message} — pass --force to overwrite it.`);
process.exitCode = 1;
return;
}
throw err;
}
console.log(`${c.green("✔")} Scaffolded .ccam/profile/ at ${resolved} (${facts.layout})`);
for (const file of result.written) console.log(` wrote ${file}`);
if (result.todos.length) {
console.log(`\n${c.yellow(`${result.todos.length} item(s) need manual attention:`)}`);
for (const todo of result.todos) console.log(` ${todo}`);
}
console.log(`\nNext: ccam lanes profile check ${repo}`);
}
/**
* `ccam lanes profile check [<path>]` validate a profile without needing a
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
* resolution (unlike every other `lanes` subcommand) this is meant to run
* against a bare repo right after `profile init`.
*/
async function cmdLanesProfileCheck(args) {
const target = args.find((arg) => !arg.startsWith("--")) || process.cwd();
const resolved = path.resolve(target);
const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js"));
const result = await laneDetect.checkProfile(resolved);
if (result.errors.length === 0) {
console.log(`${c.green("✔")} profile at ${resolved} looks good`);
} else {
console.log(`${c.red(`${result.errors.length} problem(s) at ${resolved}:`)}`);
for (const error of result.errors) console.log(` ${error}`);
}
for (const warning of result.warnings) console.log(`${c.yellow("⚠")} ${warning}`);
process.exitCode = result.ok ? 0 : 1;
}
// Keep these confirmation facts in lockstep with expectedFields() in
// server/routes/lanes.js. The server remains authoritative and rejects an
// incomplete or stale echo, while the CLI shows exactly what it will send.
@@ -1543,13 +1671,19 @@ async function cmdLanesLifecycle(action, args) {
const id = args.find((arg) => !arg.startsWith("--"));
const fields = LANE_PREFLIGHT_FIELDS[action];
if (!id) {
console.error(`usage: ccam lanes ${action} <id> [--force] --yes`);
const extra = action === "reset" ? " [--keep-db]" : "";
console.error(`usage: ccam lanes ${action} <id> [--force]${extra} --yes`);
process.exitCode = 1;
return;
}
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) {
const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated";
console.log(` database: ${preflight.database} (${fate})`);
}
if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
console.log("Warnings:");
for (const warning of preflight.warnings) console.log(` ${warning}`);
@@ -1579,6 +1713,7 @@ async function cmdLanesLifecycle(action, args) {
const expect = Object.fromEntries(fields.map((field) => [field, preflight[field]]));
const body = { confirm: true, expect };
if (args.includes("--force")) body.force = true;
if (keepDb) body.keepDb = true;
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
if (result.status) {
const error = result.data?.error || {};
@@ -1638,11 +1773,583 @@ async function cmdLanes() {
);
}
/**
* Which lane a command is about: an explicit id, or the lane owning the working
* directory (longest path-boundary match, the same rule the server uses). A
* session running inside a lane never has to know its own id.
*
* Only the FIRST positional counts as an id, and only when it is all digits.
* Scanning the whole argv for a number would swallow flag values
* `ccam lanes logs web --tail 4096` would have addressed lane 4096.
*
* @param {string[]} args - Arguments after the subcommand.
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
*/
async function resolveLaneArg(args) {
const flagValue = (name) => {
const i = args.indexOf(`--${name}`);
return i > -1 ? args[i + 1] : undefined;
};
const explicit = flagValue("lane");
if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
const { lanes } = await get("/api/lanes");
const match = lanes
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
if (!match) {
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
process.exitCode = 1;
return null;
}
return { laneId: match.id, rest: args };
}
/** One line per declared port: name, number, listening, and any base drift. */
function printRuntime(runtime) {
if (!runtime.available) {
console.log("no .ccam/profile for this lane — nothing to run.");
if (runtime.searched) for (const p of runtime.searched) console.log(` looked in ${p}`);
return;
}
if (!runtime.provisioned) {
console.log("profile found, runtime not provisioned yet — run: ccam lanes up");
return;
}
console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`);
if (runtime.database) {
console.log(` database ${runtime.database.name} (test: ${runtime.database.testName})`);
}
if (runtime.redisIndex != null) {
console.log(` redis logical db ${runtime.redisIndex}`);
}
for (const [name, info] of Object.entries(runtime.ports)) {
const drift =
info.port && info.port !== info.expected ? ` ⚠ base expects ${info.expected}` : "";
console.log(
` ${name.padEnd(10)} :${String(info.port ?? "-").padEnd(6)} ` +
`${info.listening ? "listening" : "down"}${drift}`
);
}
for (const service of runtime.services) {
console.log(
` ${service.name.padEnd(10)} pid ${service.pid} ${service.alive ? "alive" : "gone"}`
);
}
if (runtime.lastError) {
console.log(` last error: ${runtime.lastError.code || ""} ${runtime.lastError.message}`);
}
if (runtime.logs.length) console.log(` logs: ${runtime.logs.join(", ")} (${runtime.logDir})`);
}
/**
* `ccam lanes up|down|runtime|logs|hook` a lane's own application stack, as
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
* lane id.
*
* Every subcommand resolves the lane from the working directory when no id is
* given, so a session inside a lane can call them without knowing its id this
* is the surface the driving skill uses.
*/
async function cmdLanesRuntime(sub, args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { laneId, rest: laneArgs } = resolved;
if (sub === "runtime") {
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
return;
}
if (sub === "up") {
const body = {};
if (laneArgs.includes("--no-build")) body.build = false;
if (laneArgs.includes("--qc")) body.qc = true;
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
if (result.status) {
console.error(`✖ up lane #${laneId}${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
// The server answers 202 and boots in the background; poll until the stack
// reports healthy or a boot error lands, so the command exits on a real
// outcome rather than on "accepted".
console.log(`lane #${laneId} booting…`);
const deadline = Date.now() + 15 * 60 * 1000;
for (;;) {
await new Promise((r) => setTimeout(r, 2000));
const runtime = await get(`/api/lanes/${laneId}/runtime`);
if (runtime.healthy) {
printRuntime(runtime);
return;
}
if (runtime.lastError) {
console.error(`${runtime.lastError.code || ""} ${runtime.lastError.message}`);
printRuntime(runtime);
process.exitCode = 1;
return;
}
if (Date.now() > deadline) {
console.error("✖ timed out waiting for the stack to become healthy");
printRuntime(runtime);
process.exitCode = 1;
return;
}
}
}
if (sub === "down") {
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
if (result.status) {
console.error(`✖ down lane #${laneId}${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`);
return;
}
if (sub === "logs") {
const svc = laneArgs.find((arg) => !arg.startsWith("--"));
if (!svc) {
console.error("usage: ccam lanes logs [<id>] <service> [--tail bytes]");
process.exitCode = 1;
return;
}
const i = laneArgs.indexOf("--tail");
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
if (!log.available) {
console.log("no logs for this lane yet.");
return;
}
if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`);
process.stdout.write(log.text);
return;
}
if (sub === "hook") {
const name = laneArgs[0];
if (!name || name.startsWith("--")) {
console.error("usage: ccam lanes hook [<id>] <name> [args…]");
process.exitCode = 1;
return;
}
const result = await post(
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
{ args: laneArgs.slice(1) },
{ allowError: true }
);
if (result.status) {
console.error(`✖ hook ${name}${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
console.log(
`lane #${laneId} running hook ${name} — follow it with: ccam lanes logs ${laneId} ${name}`
);
}
if (sub === "sync-base") {
const mode = laneArgs.includes("--check")
? "check"
: laneArgs.includes("--continue")
? "continue"
: "merge";
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
const result = await post(
`/api/lanes/${laneId}/sync-base`,
{ mode, branch },
{ allowError: true }
);
if (result.status) {
console.error(`✖ sync-base → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
if (result.code === 5) {
console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
for (const c of result.collisions) {
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
}
process.exitCode = 5;
return;
}
if (result.code === 4) {
console.error(`✖ lane #${laneId} — MERGE CONFLICT (left in place).`);
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
console.error(
` then: ccam lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
);
process.exitCode = 4;
return;
}
if (mode === "check") {
if (result.devDelta === null) {
console.log("DEV_DELTA: unknown (no merge-base with origin/development)");
} else {
console.log(
`DEV_DELTA: ${result.devDelta.length} file(s) changed on origin/development since merge-base`
);
for (const f of result.devDelta) console.log(` ${f}`);
if (result.overlap.length) {
console.log(
`DEV_OVERLAP: ${result.overlap.length} file(s) — the upstream delta touches the feature's files:`
);
for (const f of result.overlap) console.log(` ${f}`);
} else {
console.log("DEV_OVERLAP: none");
}
}
console.log(`lane #${laneId} preflight vs origin/development: OK`);
return;
}
console.log(
`lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
);
return;
}
}
/**
* The default lock holder identity for the calling lane: `lane<slot>` when
* the lane has one allocated, else `lane<id>` a lane's own row id as a
* fallback for a lane that has never brought its runtime up. `--holder`
* always overrides both.
*
* @param {string[]} argsAfterName - Args AFTER the lock name has already been
* consumed by the caller (mirrors `cmdStage`'s `resolveLaneArg(args.slice(1))`
* call) `resolveLaneArg` treats a leading all-digits positional as a lane
* id, so the lock name itself must never reach it (a lock literally named
* e.g. "3" would otherwise be misread as lane 3).
*/
async function defaultHolder(argsAfterName) {
const explicit = (() => {
const i = argsAfterName.indexOf("--holder");
return i > -1 ? argsAfterName[i + 1] : undefined;
})();
if (explicit) return explicit;
const resolved = await resolveLaneArg(argsAfterName);
if (!resolved) return null;
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
}
function fmtLockRow(lock) {
const mins = Math.floor(lock.ageSec / 60);
return `${lock.name.padEnd(20)} held by ${lock.holder.padEnd(10)} for ${mins}m`;
}
/** `ccam lock status [<name>]` — one lock, or every held lock. */
async function cmdLockStatus(args) {
const name = args.find((arg) => !arg.startsWith("--"));
if (name) {
const { locks } = await get("/api/locks");
const lock = locks.find((l) => l.name === name);
console.log(lock ? fmtLockRow(lock) : `${name}: free`);
return;
}
const { locks } = await get("/api/locks");
if (!locks.length) {
console.log("no locks held");
return;
}
for (const lock of locks) console.log(fmtLockRow(lock));
}
/**
* `ccam lock acquire <name> [--holder X] [--timeout N]` polls until the
* lock is free (or `--timeout` seconds elapse). Prints a status line every
* ~60s of continued waiting so a long wait never reads as a hung command
* this is the CLI-side "heartbeat" the design calls for; it is terminal
* output, not a dashboard liveness signal.
*/
async function cmdLockAcquire(args) {
const name = args.find((arg) => !arg.startsWith("--"));
if (!name) {
console.error("usage: ccam lock acquire <name> [--holder X] [--timeout seconds]");
process.exitCode = 1;
return;
}
// Strip the lock name before handing args to defaultHolder/resolveLaneArg —
// see defaultHolder's doc comment for why the name must never reach it.
const holder = await defaultHolder(args.filter((a) => a !== name));
if (!holder) return; // resolveLaneArg already printed an error
const timeoutIdx = args.indexOf("--timeout");
const timeoutMs =
timeoutIdx > -1 && args[timeoutIdx + 1] ? Number(args[timeoutIdx + 1]) * 1000 : null;
const deadline = timeoutMs ? Date.now() + timeoutMs : null;
const startedAt = Date.now();
let lastPrinted = 0;
for (;;) {
const result = await post(
`/api/locks/${encodeURIComponent(name)}/acquire`,
{ holder },
{
allowError: true,
}
);
if (result.status === undefined || result.data?.acquired) {
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
return;
}
if (Date.now() - lastPrinted >= 60_000) {
const waited = Math.floor((Date.now() - startedAt) / 1000);
console.log(
`… still waiting for lock "${name}" (held by ${result.data?.holder ?? "unknown"}, waited ${waited}s)`
);
lastPrinted = Date.now();
}
if (deadline && Date.now() >= deadline) {
console.error(`✖ timed out waiting for lock "${name}"`);
process.exitCode = 1;
return;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
/** `ccam lock release <name> [--holder X]`. */
async function cmdLockRelease(args) {
const name = args.find((arg) => !arg.startsWith("--"));
if (!name) {
console.error("usage: ccam lock release <name> [--holder X]");
process.exitCode = 1;
return;
}
const holder = await defaultHolder(args.filter((a) => a !== name));
if (!holder) return;
const result = await post(
`/api/locks/${encodeURIComponent(name)}/release`,
{ holder },
{
allowError: true,
}
);
if (result.status) {
console.error(`✖ release lock "${name}" → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
console.log(`${c.green("✔")} released lock "${name}"`);
}
/**
* `ccam stage <stage> [flags]` the lane equivalent of Shipyard's
* `state.sh N set stage=…`. A skill calls this at each phase boundary so the
* dashboard shows a declared stage instead of an inferred one.
*/
function fmtFeatureRow(f) {
const marker = f.archived_at ? " " : "▶ ";
return `${marker}${f.slug.padEnd(24)} ${String(f.stage).padEnd(12)} ${f.progress}%${
f.archived_at ? ` (archived ${fmtTime(f.archived_at)})` : ""
}`;
}
/** `ccam feature list [<id>] [--cwd path]` — every feature this lane has activated. */
async function cmdFeatureList(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
if (!features.length) {
console.log("no features activated yet — start one with: ccam feature activate <slug>");
return;
}
for (const f of features) console.log(fmtFeatureRow(f));
}
/** `ccam feature activate <slug> [--title X] [<id>] [--cwd path]`. */
async function cmdFeatureActivate(args) {
const slug = args.find((arg) => !arg.startsWith("--"));
if (!slug) {
console.error("usage: ccam feature activate <slug> [--title text]");
process.exitCode = 1;
return;
}
const flag = (name) => {
const i = args.indexOf(`--${name}`);
return i > -1 ? args[i + 1] : undefined;
};
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return;
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
slug,
title: flag("title"),
});
console.log(
`${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
);
}
/** `ccam feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
/** `ccam lanes proof-link [<id>] [--cwd path]` converge the clone-root
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
* by anything else in this codebase; a session or hook calls it explicitly. */
async function cmdLanesProofLink(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
console.log(
linked
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
: "proof/ already linked, nothing to do"
);
}
/** `ccam lanes agents install [<id>] [--cwd path]` write the ship-feature-lane
* agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/,
* git-excluded. Never run automatically; a session calls it explicitly. */
async function cmdLanesAgentsInstall(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const result = await post(
`/api/lanes/${resolved.laneId}/agents/install`,
{},
{ allowError: true }
);
if (result.status) {
console.error(`✖ agents install → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
console.log(`${c.green("✔")} installed: ${result.installed.join(", ")}`);
}
/** `ccam lanes mcp sync [<id>] [--cwd path]` relocate the source repo's
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
* profiles. Never run automatically; a session calls it explicitly. */
async function cmdLanesMcpSync(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
if (result.status) {
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
console.log(`${c.green("✔")} synced: ${result.servers.join(", ")}`);
if (result.profilesSeeded.length) {
console.log(` seeded profiles: ${result.profilesSeeded.join(", ")}`);
}
}
/** `ccam lanes integration <name> [<id>]` check whether a named
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
* its .ccam/profile/integrations.env. Exit 0 = on, 1 = off. */
async function cmdLanesIntegration(args) {
const name = args.find((arg) => !arg.startsWith("--"));
if (!name) {
console.error("usage: ccam lanes integration <name> [<id>]");
process.exitCode = 1;
return;
}
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
if (!resolved) return;
const { enabled } = await get(
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
);
console.log(enabled ? "on" : "off");
process.exitCode = enabled ? 0 : 1;
}
/**
* `ccam lanes pipeline [<template-id>] [<id>]` show or switch which pipeline
* template a lane renders against. `ccam lanes add --pipeline` could only set
* this at creation time, so every lane added from the dashboard's "+ Add lane"
* was stuck on `default` with no way back to a 16-node template.
*/
async function cmdLanesPipeline(args) {
const target = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
const resolved = await resolveLaneArg(args.filter((a) => a !== target));
if (!resolved) return;
if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines");
console.log(`lane #${lane.id}${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return;
}
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
console.log(
`${c.green("✔")} lane #${lane.id} → pipeline ${lane.pipeline} ` +
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
);
// Switching templates re-resolves the SAME declared stage string against a
// different node list, so a stage that meant something in the old pipeline
// can land nowhere in the new one. Same warning as `ccam stage`, same reason.
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! the lane's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
`declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
)
);
}
}
/**
* Attach a real terminal to the exact tmux session the dashboard uses for
* this lane's run. Creates it if it doesn't exist yet (`tmux new-session -A`
* is create-or-attach, the same idempotent semantics as clicking Start on
* the dashboard). The user types `claude` themselves inside this command's
* only job is landing them in the right named session.
*/
async function cmdLanesShell(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const sessionName = `ccam-lane-${lane.id}`;
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
stdio: "inherit",
});
await new Promise((resolve) => {
child.on("close", resolve);
child.on("error", (err) => {
console.error(c.red(`${err?.message || err}`));
process.exitCode = 1;
resolve();
});
});
}
async function cmdFeatureShow(args) {
const slug = args.find((arg) => !arg.startsWith("--"));
if (!slug) {
console.error("usage: ccam feature show <slug>");
process.exitCode = 1;
return;
}
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return;
const result = await get(
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
undefined,
{ allowError: true }
);
if (result.status) {
console.error(`✖ feature "${slug}" → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
const f = result.feature;
console.log(`${f.slug} ${f.archived_at ? "(archived)" : "(active)"}`);
console.log(` stage: ${f.stage} status: ${f.status} progress: ${f.progress}%`);
for (const node of f.pipeline_nodes) console.log(` ${node.state.padEnd(18)} ${node.label}`);
}
async function cmdStage(args) {
const stage = args[0];
if (!stage || stage.startsWith("--")) {
@@ -1657,20 +2364,11 @@ async function cmdStage(args) {
return i > -1 ? args[i + 1] : undefined;
};
let laneId = flag("lane");
if (!laneId) {
const cwd = require("path").resolve(flag("cwd") || process.cwd());
const { lanes } = await get("/api/lanes");
const match = lanes
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
if (!match) {
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
process.exitCode = 1;
return;
}
laneId = match.id;
}
// `--lane` wins, otherwise the lane owning this directory. The stage name is
// args[0], so only what follows it can carry a lane reference.
const resolved = await resolveLaneArg(args.slice(1));
if (!resolved) return;
const laneId = resolved.laneId;
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
stage,
@@ -1680,6 +2378,22 @@ async function cmdStage(args) {
result: flag("result"),
});
console.log(`lane #${lane.id}${lane.stage} (${lane.progress}%)`);
// A stage name matching no node (nor alias) still stores — setStage takes the
// string verbatim — but phaseIdx() then returns -1, so nothing renders as
// `current` and progress reads 0. Warn, never fail: a typo must not break a
// declaration the pipeline can still record, but it must not pass silently.
// Skipped for `--result fail`, which paints the node `failed` rather than
// `current` and would otherwise look identical to an unknown stage.
const nodes = lane.pipeline_nodes || [];
if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
`pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
)
);
}
}
// ── Command catalog ─────────────────────────────────────────────────────────
@@ -1780,12 +2494,91 @@ const COMMAND_GROUPS = [
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
"Provision a managed worktree lane",
],
[
"lanes profile init",
"<repo> [--force]",
"Detect a Node.js project and scaffold .ccam/profile/",
],
[
"lanes profile check",
"[<path>]",
"Validate a profile (path defaults to cwd, not a lane id)",
],
[
"lanes pipeline",
"[<template-id>] [<id>]",
"Show, or switch, which pipeline template a lane renders against",
],
[
"lanes shell",
"[<id>]",
"Attach a real terminal to the exact tmux session the dashboard uses for a lane's run",
],
[
"lanes reset|remove|purge",
"<id> [--force] --yes",
"Show preflight facts, then perform a destructive lane action",
"<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
],
[
"lanes up|down",
"[<id>] [--no-build] [--qc]",
"Boot or stop the lane's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the lane owning this directory)",
],
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the lane's hook or service logs"],
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[
"lanes sync-base",
"[<id>] [--check|--continue] [branch]",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the lane's current branch)",
],
[
"lanes proof-link",
"[<id>]",
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
],
[
"lanes agents install",
"[<id>]",
"Write the ship-feature-lane agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/, git-excluded",
],
[
"lanes mcp sync",
"[<id>]",
"Relocate the source repo's MCP servers into <lane>/.mcp.json and seed Chromium profiles",
],
[
"lanes integration",
"<name> [<id>]",
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1",
],
[
"lanes gc",
"[--dry-run]",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
],
[
"skills install",
"",
"Install .claude/skills/ship-feature-lane/ into ~/.claude/skills/ so /ship-feature-lane works from any lane",
],
[
"lock status|acquire|release",
"[<name>] [--holder X] [--timeout N]",
"Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)",
],
["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"],
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
[
"feature activate",
"<slug> [--title text] [<id>]",
"Switch to a feature by slug, archiving the current one first (echoes the canonicalized slug)",
],
[
"feature show",
"<slug> [<id>]",
"Show one feature's saved pipeline (works on an archived one too)",
],
],
],
[
@@ -2571,12 +3364,71 @@ async function runCommand(argv) {
if (rest[0] === "add") {
return cmdLanesAdd(rest.slice(1));
}
if (rest[0] === "profile") {
if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2));
if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2));
console.error(
"usage: ccam lanes profile init <repo> [--force] | ccam lanes profile check [<path>]"
);
process.exitCode = 1;
return;
}
if (["reset", "remove", "purge"].includes(rest[0])) {
return cmdLanesLifecycle(rest[0], rest.slice(1));
}
if (["up", "down", "runtime", "logs", "hook", "sync-base"].includes(rest[0])) {
return cmdLanesRuntime(rest[0], rest.slice(1));
}
if (rest[0] === "proof-link") return cmdLanesProofLink(rest.slice(1));
if (rest[0] === "agents" && rest[1] === "install") {
return cmdLanesAgentsInstall(rest.slice(2));
}
if (rest[0] === "mcp" && rest[1] === "sync") {
return cmdLanesMcpSync(rest.slice(2));
}
if (rest[0] === "integration") {
return cmdLanesIntegration(rest.slice(1));
}
if (rest[0] === "pipeline") {
return cmdLanesPipeline(rest.slice(1));
}
if (rest[0] === "shell") {
return cmdLanesShell(rest.slice(1));
}
if (rest[0] === "gc") {
return cmdLanesGc(rest.slice(1));
}
return cmdLanes();
case "skills": {
if (rest[0] === "install") return cmdSkillsInstall();
console.error("usage: ccam skills install");
process.exitCode = 1;
return;
}
case "lock": {
const sub = rest[0];
if (sub === "status") return cmdLockStatus(rest.slice(1));
if (sub === "acquire") return cmdLockAcquire(rest.slice(1));
if (sub === "release") return cmdLockRelease(rest.slice(1));
console.error(
"usage: ccam lock status [<name>] | ccam lock acquire <name> [--holder X] [--timeout N] | ccam lock release <name> [--holder X]"
);
process.exitCode = 1;
return;
}
case "stage":
return cmdStage(rest);
case "feature": {
const sub = rest[0];
if (sub === "list") return cmdFeatureList(rest.slice(1));
if (sub === "activate") return cmdFeatureActivate(rest.slice(1));
if (sub === "show") return cmdFeatureShow(rest.slice(1));
console.error(
"usage: ccam feature list | ccam feature activate <slug> [--title text] | ccam feature show <slug>"
);
process.exitCode = 1;
return;
}
case "open":
return cmdOpen();
case "version":
+3
View File
@@ -400,6 +400,9 @@ Server broadcasts these event types over WebSocket:
| `notification.received` | Notification object | Notification hook |
| `remote_source.status` | `{ id, status, error?, last_sync_at? }` (`status`: `idle`/`syncing`/`ok`/`error`/`deleted`) | Remote Data Source sync poller + `/api/remote-sources` routes |
| `remote_data.updated` | `{ sourceId, source, label?, counters?, last_sync_at? }` | Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts `session_created` / `session_updated` (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately |
| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a lane's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang |
| `lane_runtime` | `{ laneId, runtime? , error? }` | A lane's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
| `lane_hook_result` | `{ laneId, hook, code, error? }` | A profile hook exited (`POST /api/lanes/:id/hook/:name`). `code` is `null` when the hook could not be started |
### EventBus Pattern
+17
View File
@@ -10,6 +10,8 @@
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"i18next": "^26.0.8",
@@ -2100,6 +2102,21 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+2
View File
@@ -13,6 +13,8 @@
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"i18next": "^26.0.8",
-5
View File
@@ -35,11 +35,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./components/Layout`
* - `./components/SplashScreen`
-5
View File
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./StatusBadge`
* - `../lib/types`
@@ -27,11 +27,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
-5
View File
@@ -31,11 +31,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `CheckboxProps` exported API; see TSDoc on the symbol for behavior.
* - `Checkbox` exported API; see TSDoc on the symbol for behavior.
-5
View File
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `DateTimePicker` exported API; see TSDoc on the symbol for behavior.
*
-5
View File
@@ -35,11 +35,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `EmptyStateProps` exported API; see TSDoc on the symbol for behavior.
* - `EmptyState` exported API; see TSDoc on the symbol for behavior.
-5
View File
@@ -24,11 +24,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/types`
* - `../lib/event-grouping`
-5
View File
@@ -24,11 +24,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/api`
* - `./DateTimePicker`
@@ -28,11 +28,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./StatusBadge`
*
-5
View File
@@ -29,11 +29,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `FieldHelpProps` exported API; see TSDoc on the symbol for behavior.
* - `FieldHelp` exported API; see TSDoc on the symbol for behavior.
-5
View File
@@ -29,11 +29,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
-5
View File
@@ -35,11 +35,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./Sidebar`
* - `./UpdateNotifier`
-5
View File
@@ -30,11 +30,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
-5
View File
@@ -29,11 +29,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `SelectOption` exported API; see TSDoc on the symbol for behavior.
* - `SelectProps` exported API; see TSDoc on the symbol for behavior.
-5
View File
@@ -23,11 +23,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./StatusBadge`
* - `../lib/types`
@@ -24,11 +24,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
-5
View File
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
-5
View File
@@ -24,11 +24,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `Skeleton` exported API; see TSDoc on the symbol for behavior.
* - `StatValueSkeleton` exported API; see TSDoc on the symbol for behavior.
-5
View File
@@ -27,11 +27,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `SplashScreen` exported API; see TSDoc on the symbol for behavior.
*
-5
View File
@@ -28,11 +28,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./Tip`
* - `./Skeleton`
-5
View File
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/types`
* - `./Tip`
@@ -26,11 +26,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./brain`
*
@@ -29,11 +29,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `SpeechBubble` exported API; see TSDoc on the symbol for behavior.
*
-5
View File
@@ -29,11 +29,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./CatAvatar`
* - `./SpeechBubble`
@@ -23,11 +23,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./brain`
*
@@ -167,26 +167,11 @@ describe("reduceTabby counts and pulses", () => {
expect(ok.state.worriedUntil).toBe(0);
});
it("run_status completed exit 0 is happy, nonzero/error/killed is worried", () => {
const good = reduceTabby(
initialTabbyState(T0),
runStatusMsg({ status: "completed", exitCode: 0 }),
T0
);
expect(good.pulse).toBe("run_done");
expect(deriveMood(good.state, T0)).toBe("happy");
const bad = reduceTabby(
initialTabbyState(T0),
runStatusMsg({ status: "completed", exitCode: 1 }),
T0
);
expect(bad.pulse).toBe("error");
const err = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "error" }), T0);
expect(err.pulse).toBe("error");
const killed = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "killed" }), T0);
expect(killed.pulse).toBe("error");
it("run_status updates activity timestamp only (no exit code available)", () => {
const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0);
expect(running.pulse).toBe(null);
const gone = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "gone" }), T0);
expect(gone.pulse).toBe(null);
});
it("any handled message refreshes lastActivityAt", () => {
+1 -24
View File
@@ -24,11 +24,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
*
@@ -345,25 +340,7 @@ export function reduceTabby(
case "run_status": {
const r = msg.data as RunStatusPayload;
if (!r) return { state, pulse: null };
// A run that finished cleanly (exit 0, or no exit code reported) → happy.
if (r.status === "completed" && (r.exitCode == null || r.exitCode === 0)) {
return {
state: { ...state, happyUntil: now + HAPPY_MS, lastActivityAt: now },
pulse: "run_done",
};
}
// Errored, killed, or completed with a nonzero exit code → worried.
if (
r.status === "error" ||
r.status === "killed" ||
(r.status === "completed" && r.exitCode != null && r.exitCode !== 0)
) {
return {
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
pulse: "error",
};
}
// spawning / running → activity only.
// running / gone → activity only (no exit code to distinguish success/failure).
return { state: { ...state, lastActivityAt: now }, pulse: null };
}
-5
View File
@@ -23,11 +23,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./brain`
*
-5
View File
@@ -22,11 +22,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `TabbyPos` exported API; see TSDoc on the symbol for behavior.
* - `tabbyPrefs` exported API; see TSDoc on the symbol for behavior.
-5
View File
@@ -22,11 +22,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./brain`
*
@@ -23,11 +23,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/eventBus`
* - `../../lib/api`
@@ -25,11 +25,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./prefs`
*
-5
View File
@@ -32,11 +32,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `Tip` exported API; see TSDoc on the symbol for behavior.
*
+98 -13
View File
@@ -2,11 +2,15 @@
* @file UpdateNotifier.tsx
* @description Modal surfaced when the dashboard's git checkout is behind its
* remote tracking branch. Shows how many commits behind, the exact terminal
* command to update, and copy-to-clipboard the dashboard never pulls or
* restarts itself.
* command to update with copy-to-clipboard, and when the checkout is on a
* fast-forwardable branch an "Update now" button that calls
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
* then polls until it's back and reloads the page.
*
* ## State sources
* - Initial fetch via `api.updates.status()` on mount.
* - Background re-check via `api.updates.check()` every hour
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
*
* ## Dismissal persistence
@@ -33,11 +37,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
@@ -68,7 +67,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, X, Copy, Check, RefreshCw } from "lucide-react";
import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -76,6 +75,15 @@ import type { UpdateStatusPayload, WSMessage } from "../lib/types";
/** `localStorage` key storing the dismissed upstream SHA. */
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
/** How often to silently re-check for updates in the background. */
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
/** Situations `POST /api/updates/apply` will actually act on mirrors the
* server-side check in `server/lib/update-check.js`'s `applyUpdate`. */
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking";
}
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
@@ -101,6 +109,8 @@ export function UpdateNotifier() {
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
setStatus(s);
@@ -133,6 +143,19 @@ export function UpdateNotifier() {
});
}, [syncFromPayload]);
// Background re-check every hour, on top of the initial mount fetch and the
// manual "Check now" button — so a long-lived tab notices an update without
// the user having to click anything.
useEffect(() => {
const id = setInterval(() => {
api.updates
.check()
.then(syncFromPayload)
.catch(() => {});
}, AUTO_CHECK_INTERVAL_MS);
return () => clearInterval(id);
}, [syncFromPayload]);
useEffect(() => {
const handler = () => setDismissedSha(null);
window.addEventListener("dashboard:reset-update-dismissal", handler);
@@ -144,14 +167,14 @@ export function UpdateNotifier() {
);
const dismiss = useCallback(() => {
if (!status?.remote_sha) return;
if (restarting || !status?.remote_sha) return;
try {
localStorage.setItem(DISMISS_KEY, status.remote_sha);
} catch {
/* ignore */
}
setDismissedSha(status.remote_sha);
}, [status?.remote_sha]);
}, [restarting, status?.remote_sha]);
// Escape to dismiss - standard modal affordance.
useEffect(() => {
@@ -188,6 +211,41 @@ export function UpdateNotifier() {
}
};
// Polls `status()` until the restarted server answers again, then reloads
// so the tab picks up the new client bundle too — the server can't push
// this over its own WebSocket since it's mid-restart.
const pollUntilBack = useCallback(() => {
const attempt = () => {
api.updates
.status()
.then(() => window.location.reload())
.catch(() => setTimeout(attempt, 1500));
};
setTimeout(attempt, 1500);
}, []);
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
setApplying(true);
try {
const result = await api.updates.apply();
if (result.applied) {
setApplying(false);
setRestarting(true);
pollUntilBack();
return;
}
setError(
result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError")
);
} catch (e) {
setError(e instanceof Error ? e.message : t("applyError"));
} finally {
setApplying(false);
}
};
if (!show || !status) return null;
const refLabel = status.remote_ref || "origin";
@@ -271,6 +329,13 @@ export function UpdateNotifier() {
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
) : null}
{restarting ? (
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
{t("restarting")}
</div>
) : null}
{error ? (
<p className="text-xs text-status-danger" role="alert">
{error}
@@ -283,13 +348,18 @@ export function UpdateNotifier() {
<button
type="button"
onClick={checkNow}
disabled={checking}
disabled={checking || applying || restarting}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
{checking ? t("checking") : t("checkNow")}
</button>
<button type="button" onClick={dismiss} className="btn-ghost">
<button
type="button"
onClick={dismiss}
disabled={restarting}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("dismiss")}
</button>
{status.manual_command ? (
@@ -297,12 +367,27 @@ export function UpdateNotifier() {
type="button"
onClick={copyCmd}
disabled={copied}
className="btn-primary disabled:opacity-70"
className="btn-ghost disabled:opacity-70"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? t("copied") : t("copy")}
</button>
) : null}
{isAutoApplicable(status.situation) ? (
<button
type="button"
onClick={applyNow}
disabled={applying || restarting}
className="btn-primary disabled:opacity-70"
>
{applying || restarting ? (
<RefreshCw className="w-4 h-4 animate-spin" aria-hidden />
) : (
<Zap className="w-4 h-4" aria-hidden />
)}
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
</button>
) : null}
</div>
</div>
</div>
@@ -27,11 +27,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/api`
* - `./Select`
@@ -0,0 +1,107 @@
/**
* @file UpdateNotifier.test.tsx
* @description Pins the "Update now" self-apply flow added on top of the
* existing manual-command modal: the button only renders when the checkout
* situation is fast-forwardable, clicking it calls `api.updates.apply()`,
* and a successful `applied: true` response flips the modal into a
* "restarting" state that polls `api.updates.status()` until it resolves.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { UpdateNotifier } from "../UpdateNotifier";
import type { UpdateStatusPayload } from "../../lib/types";
const statusMock = vi.fn();
const checkMock = vi.fn();
const applyMock = vi.fn();
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
updates: {
status: (...args: unknown[]) => statusMock(...args),
check: (...args: unknown[]) => checkMock(...args),
apply: (...args: unknown[]) => applyMock(...args),
},
},
};
});
const BASE_STATUS: UpdateStatusPayload = {
git_repo: true,
update_available: true,
repo_root: "/repo",
remote_ref: "origin/main",
canonical_remote: "origin",
current_branch: "main",
tracking_upstream: "origin/main",
tracks_canonical: true,
situation: "tracking_canonical",
situation_note: null,
local_sha: "aaa",
remote_sha: "bbb",
commits_behind: 2,
manual_command: 'cd "/repo" && git pull --ff-only && npm run setup',
message: "2 commit(s) on origin/main not in your checkout.",
};
beforeEach(() => {
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
applyMock.mockReset();
try {
localStorage.clear();
} catch {
// Some CI environments stub a non-functional localStorage; the component
// already tolerates that (see UpdateNotifier's own try/catch), so tests do too.
}
});
describe("UpdateNotifier — Update now", () => {
it("shows the Update now button for a fast-forwardable checkout and applies on click", async () => {
applyMock.mockResolvedValue({ ...BASE_STATUS, applied: true, update_available: false });
render(<UpdateNotifier />);
const updateBtn = await screen.findByText(i18n.t("updates:updateNow"));
await userEvent.click(updateBtn);
await waitFor(() => expect(applyMock).toHaveBeenCalled());
// Appears both in the status banner and the button label while restarting.
const restartingNodes = await screen.findAllByText(i18n.t("updates:restarting"));
expect(restartingNodes.length).toBeGreaterThan(0);
});
it("hides the Update now button for a non-fast-forwardable branch", async () => {
statusMock.mockResolvedValue({
...BASE_STATUS,
situation: "feature_branch",
tracks_canonical: false,
});
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument();
});
it("surfaces the decline reason instead of restarting on a 409", async () => {
applyMock.mockResolvedValue({
...BASE_STATUS,
applied: false,
reason: "not_fast_forwardable",
});
render(<UpdateNotifier />);
const updateBtn = await screen.findByText(i18n.t("updates:updateNow"));
await userEvent.click(updateBtn);
expect(await screen.findByText(i18n.t("updates:reasonNotFastForwardable"))).toBeInTheDocument();
expect(screen.queryByText(i18n.t("updates:restarting"))).not.toBeInTheDocument();
});
});
@@ -22,11 +22,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/highlight`
*
@@ -25,11 +25,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/api`
* - `../../lib/eventBus`
@@ -29,11 +29,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./CodeBlock`
*
@@ -24,11 +24,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
* - `./ToolCallBlock`
@@ -24,11 +24,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
* - `./CodeBlock`
@@ -22,11 +22,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `ToolStyle` exported API; see TSDoc on the symbol for behavior.
* - `styleForTool` exported API; see TSDoc on the symbol for behavior.
@@ -24,11 +24,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `TuiSegment` exported API; see TSDoc on the symbol for behavior.
* - `stripAnsi` exported API; see TSDoc on the symbol for behavior.
@@ -24,11 +24,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `CopyButton` exported API; see TSDoc on the symbol for behavior.
* - `Terminal` exported API; see TSDoc on the symbol for behavior.
@@ -36,11 +36,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `./primitives`
*
+289 -20
View File
@@ -1,24 +1,76 @@
/**
* @file AddLaneModal.tsx
* @description The "+ Add lane" flow: pick a SOURCE repo (not a folder to
* adopt), pick which of its branches to fork from, name the feature, and the
* dashboard provisions a managed git worktree via `POST /api/lanes/worktree`
* the dashboard invents the lane's own directory and branch name, the same
* way Shipyard's "+ Add lane" never asks a human to name a folder. The lane
* returned is `status: "provisioning"`; the existing `lane_update` WebSocket
* subscription in the Workspace page flips it to idle when the worktree is
* actually ready, so this component does not poll.
* @description The "+ Add lane" flow, in one of two modes chosen with a
* segmented toggle: "Repo" adopts an existing directory as-is via
* `POST /api/lanes/ensure` (no worktree, no branch the right mode for a
* main repo you want stage detection on); "Worktree" provisions a
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
* manually-typed branch name. Either mode's path field can be filled by
* typing, by the CwdAutocomplete suggestions, or by browsing
* (`FolderBrowseModal`) a native folder picker cannot hand a web page an
* absolute filesystem path, so browsing is server-backed instead. The
* worktree lane returned is `status: "provisioning"`: the route answers
* before the actual `git worktree add` runs, so this component polls
* `GET /api/lanes/:id` until that finishes before running the auto-setup
* calls against a cwd that must actually exist on disk first.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { ConfirmModal } from "../ConfirmModal";
import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { api } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane } from "../../lib/types";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
* lane record, or `null` on timeout. */
async function waitForProvisioned(
laneId: number,
{ intervalMs = 500, timeoutMs = 30000 } = {}
): Promise<Lane | null> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const { lane } = await api.lanes.get(laneId);
if (lane.status !== "provisioning") return lane;
if (Date.now() >= deadline) return null;
await new Promise((r) => window.setTimeout(r, intervalMs));
}
}
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
function Seg({
active,
label,
title,
onClick,
}: {
active: boolean;
label: string;
title?: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-pressed={active}
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
active
? "bg-accent text-white shadow-sm"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{label}
</button>
);
}
export function AddLaneModal({
open,
onClose,
@@ -27,36 +79,74 @@ export function AddLaneModal({
}: {
open: boolean;
onClose: () => void;
/** Called with the newly provisioned (still-provisioning) lane. */
/** Called with the newly created (possibly still-provisioning) lane. */
onAdded: (lane: Lane) => void;
/** The same suggestion list the Run form already fetched (dashboard cwd,
* home, recently-used paths) reused rather than fetched a second time. */
cwdSuggestions: CwdSuggestion[];
}) {
const { t } = useTranslation(["lanes"]);
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
const [sourceRepo, setSourceRepo] = useState("");
const [title, setTitle] = useState("");
const [branch, setBranch] = useState("");
const [branches, setBranches] = useState<string[] | null>(null);
const [base, setBase] = useState("");
const [pipeline, setPipeline] = useState("default");
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [setupResult, setSetupResult] = useState<{
profile: "scaffolded" | "skipped" | "failed";
agents: "ok" | "failed";
mcp: "ok" | "failed";
} | null>(null);
const reset = () => {
setMode("worktree");
setSourceRepo("");
setTitle("");
setBranch("");
setBranches(null);
setBase("");
setPipeline("default");
setBranchesError(null);
setError(null);
setBusy(false);
setSetupResult(null);
};
// The template a lane is created with is the ONLY chance to get it right
// from here: nothing else in the UI can change it afterwards, so a lane
// silently born on `default` renders an 8-node map for a 16-node workflow.
// Fetched on open (templates are file-backed and can change between opens).
useEffect(() => {
if (!open) return;
let cancelled = false;
api.lanes
.pipelines()
.then((r) => {
if (!cancelled) setPipelines(r.pipelines);
})
.catch(() => {
// Quiet: the select just falls back to the single `default` option
// below, and the lane still gets created.
if (!cancelled) setPipelines([]);
});
return () => {
cancelled = true;
};
}, [open]);
// Look up the repo's branches once the path settles - debounced so every
// keystroke while typing a path doesn't fire a request against a path that
// isn't finished yet.
// isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and
// never forks a branch.
const lookedUpFor = useRef<string>("");
useEffect(() => {
if (mode !== "worktree") return;
const path = sourceRepo.trim();
if (!path) {
setBranches(null);
@@ -82,23 +172,84 @@ export function AddLaneModal({
}
}, 300);
return () => window.clearTimeout(timer);
}, [sourceRepo, t]);
}, [mode, sourceRepo, t]);
const submit = async () => {
const repo = sourceRepo.trim();
const name = title.trim();
if (!repo || !branches || !name) return;
if (!repo) return;
setBusy(true);
setError(null);
if (mode === "repo") {
try {
const result = await api.lanes.ensure({
cwd: repo,
title: name || undefined,
pipeline,
});
onAdded(result.lane);
reset();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
return;
}
if (!branches || !name || !branch.trim()) return;
try {
const result = await api.lanes.worktree({
sourceRepo: repo,
title: name,
base: base || undefined,
branch: branch.trim(),
pipeline,
});
reset();
onAdded(result.lane);
onClose();
// POST /worktree returns as soon as the DB row exists (202) — the
// actual `git worktree add` runs afterward, in the background, on the
// server. Firing setup against the lane's cwd before that finishes
// means agents/mcp write into a directory that doesn't exist yet, so
// wait for provisioning to leave the "provisioning" status first.
const provisionedLane = await waitForProvisioned(result.lane.id);
if (!provisionedLane || provisionedLane.status === "failed") {
setBusy(false);
setError(t("addLaneProvisionFailed"));
return;
}
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
api.lanes.profileInit(result.lane.id),
api.lanes.agentsInstall(result.lane.id),
api.lanes.mcpSync(result.lane.id),
]);
setSetupResult({
profile:
profileOutcome.status === "fulfilled"
? profileOutcome.value.scaffolded
? "scaffolded"
: "skipped"
: "failed",
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
});
if (import.meta.env.DEV) {
console.info("[add-lane] auto-setup result:", {
profile:
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
});
}
// Leave the modal open so the setup summary below stays on screen; the
// user dismisses it themselves (Cancel/X) once they've seen it, rather
// than racing a timer that can close before they've looked at it.
setBusy(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
@@ -112,9 +263,21 @@ export function AddLaneModal({
// into after the very first character. useCallback keeps the identity stable
// across renders so only mount/unmount (and a real onClose change) refocuses.
const handleCancel = useCallback(() => {
// ConfirmModal's own Escape/backdrop/X handling calls this directly.
// While the folder browser is open on top of it, that dismissal should
// close only the browser, not both modals at once.
if (browseOpen) {
setBrowseOpen(false);
return;
}
reset();
onClose();
}, [onClose]);
}, [onClose, browseOpen]);
const disabled =
!!setupResult ||
!sourceRepo.trim() ||
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
return (
<ConfirmModal
@@ -124,22 +287,52 @@ export function AddLaneModal({
cancelLabel={t("destructive.cancel")}
destructive={false}
busy={busy}
disabled={!sourceRepo.trim() || !title.trim() || !branches}
disabled={disabled}
onConfirm={submit}
onCancel={handleCancel}
>
<div className="space-y-3">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={mode === "repo"}
label={t("mode.repo")}
title={t("mode.repoHint")}
onClick={() => setMode("repo")}
/>
<Seg
active={mode === "worktree"}
label={t("mode.worktree")}
title={t("mode.worktreeHint")}
onClick={() => setMode("worktree")}
/>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{t("addLaneRepoLabel")}
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5">
<div className="min-w-0 flex-1">
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
onChange={setSourceRepo}
suggestions={cwdSuggestions}
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneRepoHint")}</p>
</div>
<button
type="button"
onClick={() => setBrowseOpen(true)}
title={t("browse.title")}
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
>
<FolderOpen className="h-3.5 w-3.5" />
{t("browse.button")}
</button>
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
</p>
</div>
<div>
@@ -155,7 +348,28 @@ export function AddLaneModal({
/>
</div>
{branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
{t("addLanePipelineLabel")}
</label>
<select
id="add-lane-pipeline"
value={pipeline}
onChange={(e) => setPipeline(e.target.value)}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
>
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
(p) => (
<option key={p.id} value={p.id}>
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
</option>
)
)}
</select>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
</div>
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
@@ -178,16 +392,71 @@ export function AddLaneModal({
)}
</div>
)}
{branchesError && !branches && (
{mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{mode === "worktree" && branches && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
</div>
)}
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
{(
[
{
label: t("addLaneSetupProfile"),
ok: setupResult.profile !== "failed",
skipped: setupResult.profile === "skipped",
},
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
] as const
).map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
<span
className={
"skipped" in row && row.skipped
? "text-fg-muted"
: row.ok
? "text-status-success"
: "text-status-danger"
}
>
{"skipped" in row && row.skipped ? "" : row.ok ? "✓" : "✗"}
</span>
{row.label}
</p>
))}
</div>
)}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
<FolderBrowseModal
open={browseOpen}
initialPath={sourceRepo.trim() || undefined}
onSelect={setSourceRepo}
onClose={() => setBrowseOpen(false)}
/>
</ConfirmModal>
);
}
@@ -0,0 +1,137 @@
/**
* @file A server-backed folder browser for the Add Lane modal's path inputs.
* Browsers refuse to expose an absolute filesystem path from a native folder
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
* (immediate subdirectories of a path) instead breadcrumb-free, just an
* up-one-level button and a click-to-descend list, since this tool is
* local-first and the server already trusts arbitrary local paths.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
import { api } from "../../lib/api";
export function FolderBrowseModal({
open,
initialPath,
onSelect,
onClose,
}: {
open: boolean;
/** Path to start browsing from; omitted defaults server-side to the home dir. */
initialPath?: string;
onSelect: (path: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
const [error, setError] = useState<string | null>(null);
const load = async (path?: string) => {
setError(null);
try {
setListing(await api.lanes.browse(path));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
useEffect(() => {
if (open) void load(initialPath);
// Only re-run when the modal actually opens - not on every initialPath
// keystroke in the field behind it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
role="presentation"
>
<div
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={t("browse.title")}
>
<div className="border-b border-border p-3">
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
{listing?.path || "…"}
</div>
</div>
<div className="flex-1 overflow-y-auto p-1">
{error && (
<p role="alert" className="p-2 text-xs text-status-danger">
{error}
</p>
)}
{listing?.parent && (
<button
type="button"
onClick={() => load(listing.parent!)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
>
<ArrowUp className="h-3.5 w-3.5" />
..
</button>
)}
{listing?.entries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => load(entry.path)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
>
{entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
)}
<span className="truncate">{entry.name}</span>
</button>
))}
{listing && listing.entries.length === 0 && !listing.parent && (
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
<button
type="button"
onClick={onClose}
className="btn-ghost border border-border text-xs"
>
{t("destructive.cancel")}
</button>
<button
type="button"
disabled={!listing}
onClick={() => {
if (listing) onSelect(listing.path);
onClose();
}}
className="btn-primary text-xs disabled:opacity-50"
>
{t("browse.select")}
</button>
</div>
</div>
</div>
);
}
+406 -1
View File
@@ -15,13 +15,23 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { api } from "../../lib/api";
import type { Lane, LaneGitFacts } from "../../lib/types";
import { eventBus } from "../../lib/eventBus";
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
* each call is three git subprocesses server-side, and a branch name does not
* change on the timescale the lane list is polled at. */
const GIT_REFRESH_MS = 30_000;
/** How often a mounted card re-probes its stack. Faster than the git refresh
* because a stack dying is exactly what the user needs to see, and slower than
* the lane poll because each call opens a socket per declared port. */
const RUNTIME_REFRESH_MS = 10_000;
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
* as the git facts (slow, since lock state changes infrequently). */
const LOCKS_REFRESH_MS = 30_000;
/**
* The lane's own working copy, fetched per card rather than folded into the
* polled lane list. Absent facts are not an error state: a lane may point at a
@@ -55,6 +65,105 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
return facts;
}
/**
* The lane's own application stack. Same shape and same silence as the git
* facts above: a lane without a `.ccam/profile` simply has no runtime row, which
* is the common case and not an error worth a banner.
*
* `bump` lets an up/down action re-read immediately instead of waiting out the
* poll interval.
*/
function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
const [runtime, setRuntime] = useState<LaneRuntime | null>(null);
useEffect(() => {
let alive = true;
const read = () => {
api.lanes
.runtime(laneId)
.then((r) => {
if (alive) setRuntime(r);
})
.catch(() => {
if (alive) setRuntime({ available: false });
});
};
read();
const timer = setInterval(read, RUNTIME_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneId, bump]);
return runtime;
}
/**
* Locks held by THIS lane, polled the same way runtime/git facts are.
*/
function useLaneLocks(laneSlot: number | null): NamedLock[] {
const [locks, setLocks] = useState<NamedLock[]>([]);
useEffect(() => {
if (!laneSlot) return;
let alive = true;
const holder = `lane${laneSlot}`;
const read = () => {
api.locks
.list()
.then((data) => {
if (alive) setLocks(data.locks.filter((l) => l.holder === holder));
})
.catch(() => {
/* fails silently, same contract as the git/runtime pollers */
});
};
read();
const timer = setInterval(read, LOCKS_REFRESH_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [laneSlot]);
return locks;
}
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
function useLaneIntegrations(
laneId: number,
available: boolean
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
const [state, setState] = useState<Record<string, boolean> | null>(null);
useEffect(() => {
if (!available) {
setState(null);
return;
}
let alive = true;
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
.then((results) => {
if (!alive) return;
const next: Record<string, boolean> = {};
INTEGRATION_NAMES.forEach((name, i) => {
next[name] = results[i]?.enabled ?? false;
});
setState(next);
})
.catch(() => {
if (alive) setState(null);
});
return () => {
alive = false;
};
}, [laneId, available]);
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
}
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
idle: "bg-surface-4",
@@ -71,16 +180,124 @@ function since(sec: number | null): string {
export default function LaneCard({
lane,
onAction,
childWorktrees,
onSelectLane,
}: {
lane: Lane;
onAction: (action: string, body?: Record<string, unknown>) => void;
/** Other lanes whose `source_repo` is this lane's `cwd` populated only
* when this lane is itself a source repo (typically an adopted one) that
* other lanes were provisioned as worktrees from. */
childWorktrees?: Lane[];
/** Jumps the Workspace page's selection to another lane's card. */
onSelectLane?: (id: number) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
null
);
const [menuOpen, setMenuOpen] = useState(false);
const [runtimeBump, setRuntimeBump] = useState(0);
const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null);
const [bootLine, setBootLine] = useState<string | null>(null);
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
null
);
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
const git = useLaneGitFacts(lane.id);
const runtime = useLaneRuntime(lane.id, runtimeBump);
const locks = useLaneLocks(lane.slot);
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
/**
* Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
* that prop drives the lane's Claude run, and folding a second lifecycle into
* it would make "stop" ambiguous about which thing it stops.
*
* `up` answers 202 and keeps booting in the background, so the button stays
* busy until the server's `lane_runtime` message says the attempt finished
* resolving the request is not the same as the stack being up.
*/
const runtimeAction = async (which: "up" | "down") => {
setRuntimeBusy(which);
setBootLine(null);
try {
if (which === "down") {
await api.lanes.down(lane.id);
setRuntimeBusy(null);
} else {
await api.lanes.up(lane.id);
}
} catch {
// The failure surfaces as the runtime row's lastError on the next read; a
// toast here would say the same thing twice.
setRuntimeBusy(null);
} finally {
setRuntimeBump((n) => n + 1);
}
};
const runLaneAction = async (
which: "agents" | "mcp" | "sync-check",
fn: () => Promise<string>
) => {
setLaneActionBusy(which);
setLaneActionResult(null);
try {
setLaneActionResult(await fn());
} catch (err) {
setLaneActionResult(err instanceof Error ? err.message : String(err));
} finally {
setLaneActionBusy(null);
}
};
const handleAgentsInstall = () =>
runLaneAction("agents", async () => {
const result = await api.lanes.agentsInstall(lane.id);
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
});
const handleMcpSync = () =>
runLaneAction("mcp", async () => {
const result = await api.lanes.mcpSync(lane.id);
return t("actions.mcpSyncResult", { servers: result.servers.join(", ") || "none" });
});
const handleSyncCheck = () =>
runLaneAction("sync-check", async () => {
const result = await api.lanes.syncBaseCheck(lane.id);
if (result.code === 5 && result.collisions && result.collisions.length > 0) {
const c = result.collisions[0]!;
return t("actions.syncCheckCollision", { file: c.file, suggestion: c.suggestion });
}
return t("actions.syncCheckClean", {
count: result.devDelta?.length ?? 0,
overlap: result.overlap?.length ? result.overlap.join(", ") : "none",
});
});
/**
* Live boot feedback. A build can run for minutes, and a card showing only a
* disabled button through all of it reads as a hang. The hook's own output
* lines are the honest progress indicator.
*/
useEffect(
() =>
eventBus.subscribe((msg) => {
const data = msg.data as { laneId?: number; line?: string } | undefined;
if (!data || data.laneId !== lane.id) return;
if (msg.type === "lane_hook_output" && typeof data.line === "string") {
setBootLine(data.line);
}
if (msg.type === "lane_runtime") {
setRuntimeBusy(null);
setBootLine(null);
setRuntimeBump((n) => n + 1);
}
}),
[lane.id]
);
return (
<>
@@ -144,6 +361,80 @@ export default function LaneCard({
</div>
)}
{/* Runtime strip: the lane's own app stack, shown only for lanes that
declare a profile. A port that drifted from `base + slot` is called
out the number is otherwise predictable from the slot, and silently
serving on a different one is exactly the surprise worth flagging. */}
{runtime?.available && runtime.provisioned && (
<div
data-testid={`lane-runtime-${lane.id}`}
className="mb-3 rounded border border-border bg-surface-2/50 px-2 py-1.5 font-mono text-[11px]"
>
<div className="mb-1 flex items-center justify-between">
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
<span
className={runtime.healthy ? "text-status-success" : "text-fg-muted"}
data-testid="lane-runtime-state"
>
{runtime.healthy
? t("runtime.healthy")
: runtime.up
? t("runtime.partial")
: t("runtime.down")}
</span>
</div>
{Object.entries(runtime.ports).map(([name, info]) => (
<div key={name} className="flex items-center gap-1.5 truncate">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
info.listening ? "bg-status-success" : "bg-surface-4"
}`}
/>
<span className="text-fg-secondary">{name}</span>
<span className="text-fg-muted">:{info.port ?? "—"}</span>
{info.port !== null && info.port !== info.expected && (
<span
className="truncate text-status-warning/80"
title={t("runtime.steppedAsideTitle", { expected: info.expected })}
>
{info.expected}
</span>
)}
</div>
))}
{/* While a boot is in flight the hook's own latest line IS the
progress bar a build can take minutes, and a disabled button
with nothing moving reads as a hang. */}
{bootLine !== null && (
<div
data-testid="lane-runtime-bootline"
className="mt-1 truncate text-fg-muted"
title={bootLine}
>
{bootLine}
</div>
)}
{bootLine === null && runtime.lastError && (
<div
className="mt-1 truncate text-status-danger/90"
title={runtime.lastError.message}
>
{runtime.lastError.code || "error"}: {runtime.lastError.message}
</div>
)}
</div>
)}
{locks.length > 0 && (
<div
className="mb-3 flex items-center gap-1 text-xs text-status-warning"
data-testid={`lane-locks-${lane.id}`}
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
>
🔒 {t("locks.held", { count: locks.length })}
</div>
)}
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
{git?.available && (
<div data-testid="lane-git" className="space-y-1">
@@ -169,6 +460,49 @@ export default function LaneCard({
</div>
</dl>
{childWorktrees && childWorktrees.length > 0 && (
<div
data-testid="lane-child-worktrees"
className="mb-3 space-y-1 text-[11px] text-fg-secondary"
>
<div className="text-fg-muted">
{t("worktrees.heading", { count: childWorktrees.length })}
</div>
<ul className="space-y-0.5">
{childWorktrees.map((w) => (
<li key={w.id}>
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-400 hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
</button>
</li>
))}
</ul>
</div>
)}
{integrations && (
<div className="mb-2 flex items-center gap-1.5 text-[10px]">
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
<span
key={name}
data-testid={`lane-integration-${name}`}
className={`rounded-full px-2 py-0.5 ${
integrations[name]
? "bg-status-success/10 text-status-success"
: "bg-surface-2 text-fg-muted"
}`}
>
{t(`integrations.${name}`)}
</span>
))}
</div>
)}
{/* mt-auto pins the controls to the bottom so cards of differing height
in one grid row still line their buttons up. */}
<div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
@@ -191,6 +525,68 @@ export default function LaneCard({
{t(`action.${a}`)}
</button>
))}
{/* Stack controls, only for a lane whose repo declares a profile.
Separate from start/stop above: those drive the lane's Claude run,
these drive the application it is working on. */}
{runtime?.available && (
<button
type="button"
data-testid="lane-runtime-toggle"
disabled={runtimeBusy !== null}
onClick={(e) => {
e.stopPropagation();
void runtimeAction(runtime.provisioned && runtime.up ? "down" : "up");
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
title={t("runtime.toggleTitle")}
>
{runtimeBusy
? t(`runtime.busy.${runtimeBusy}`)
: runtime.provisioned && runtime.up
? t("runtime.stop")
: t("runtime.boot")}
</button>
)}
{runtime?.available && (
<>
<button
type="button"
data-testid="lane-agents-install"
disabled={laneActionBusy !== null}
onClick={(e) => {
e.stopPropagation();
void handleAgentsInstall();
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
>
{laneActionBusy === "agents" ? t("actions.busy") : t("actions.agentsInstall")}
</button>
<button
type="button"
data-testid="lane-mcp-sync"
disabled={laneActionBusy !== null}
onClick={(e) => {
e.stopPropagation();
void handleMcpSync();
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
>
{laneActionBusy === "mcp" ? t("actions.busy") : t("actions.mcpSync")}
</button>
<button
type="button"
data-testid="lane-sync-check"
disabled={laneActionBusy !== null}
onClick={(e) => {
e.stopPropagation();
void handleSyncCheck();
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
>
{laneActionBusy === "sync-check" ? t("actions.busy") : t("actions.syncCheck")}
</button>
</>
)}
{/* Deleting the lane and deleting its history are each their own
button, by request: hiding "delete" behind a made it unfindable,
and the one label that read as "delete" was `clear`. Neither fires
@@ -263,6 +659,15 @@ export default function LaneCard({
)}
</div>
</div>
{laneActionResult && (
<p
data-testid="lane-action-result"
className="mt-1 truncate text-[11px] text-fg-secondary"
>
{laneActionResult}
</p>
)}
</div>
{destructiveAction && (
@@ -1,9 +1,9 @@
/**
* @file The compact lane tile used in the Workspace carousel. It carries only
* what you need to pick a lane which lane, is it alive, what stage, how far
* because the full card, its controls and its working-copy facts live in the
* detail panel below. Keeping the tile small is what lets a dozen lanes stay
* scannable in one horizontal row.
* @file The compact lane tile used in the Workspace's vertical lane list. It
* carries only what you need to pick a lane which lane, is it alive, what
* stage, how far because the full card, its controls and its working-copy
* facts live in the detail panel beside it. Keeping the tile small and full
* width is what lets many lanes stay scannable in one scrolling column.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
@@ -42,7 +42,7 @@ export default function LaneStripCard({
aria-pressed={selected}
onClick={onSelect}
title={lane.cwd}
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left shadow-sm transition-colors ${
className={`w-full shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
selected
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
@@ -1,11 +1,11 @@
/**
* @file AddLaneModal.test.tsx
* @description Pins the "+ Add lane" flow after it was rebuilt around a source
* repo instead of an existing folder: picking or typing a repo path triggers a
* branch lookup, the base-branch picker only appears once that lookup resolves,
* confirm submits through the provisioning endpoint (not the adopt/ensure one),
* an unresolvable path degrades to a quiet hint instead of blocking the form,
* and a server error surfaces instead of closing the modal.
* @description Pins the "+ Add lane" flow's two modes: "Worktree" (default -
* pick a source repo, fork a branch, type a new branch name, submit through
* the provisioning endpoint) and "Repo" (adopt a directory as-is through
* `ensure`, no branch fields). Also covers the branch-lookup debounce, the
* unresolvable-path degrade, server-error handling, the auto-setup summary,
* the provisioning-wait race, and the folder-browse modal.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
@@ -18,16 +18,33 @@ import type { CwdSuggestion } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: { lanes: { branches: vi.fn(), worktree: vi.fn() } },
api: {
lanes: {
branches: vi.fn(),
pipelines: vi.fn(),
worktree: vi.fn(),
ensure: vi.fn(),
browse: vi.fn(),
get: vi.fn(),
profileInit: vi.fn(),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
},
},
}));
function laneFixture(over: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
// base object below still lists all of them, so the cast asserts what the
// literal already proves.
return {
id: 9,
title: "",
cwd: "/lanes/repo__feature",
branch: "feat/feature",
kind: "managed",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
@@ -48,8 +65,10 @@ function laneFixture(over: Partial<Lane> = {}): Lane {
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
...over,
};
} as Lane;
}
const SUGGESTIONS: CwdSuggestion[] = [
@@ -59,13 +78,7 @@ const SUGGESTIONS: CwdSuggestion[] = [
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
return render(
<AddLaneModal
open
onClose={vi.fn()}
onAdded={vi.fn()}
cwdSuggestions={SUGGESTIONS}
{...over}
/>
<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={SUGGESTIONS} {...over} />
);
}
@@ -77,13 +90,51 @@ async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElem
await user.click(el);
}
/** Fills the default "Worktree" mode's form up through a resolved branch
* list, title, and new-branch name - everything Add lane needs to enable. */
async function fillWorktreeForm(
user: ReturnType<typeof userEvent.setup>,
{ repo = "/Users/tester/projects/repo", title = "demo", branch = "feat/demo" } = {}
) {
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, repo);
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), title);
await user.type(screen.getByLabelText("New branch name"), branch);
}
beforeEach(() => {
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.pipelines)
.mockReset()
.mockResolvedValue({
pipelines: [
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
{
id: "ship-feature",
name: "Ship feature (lane pipeline)",
nodes: new Array(16).fill({ id: "n" }),
},
],
});
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.browse).mockReset();
// Provisioning finishes instantly by default - tests that care about the
// provisioning-in-progress race override this per-test.
vi.mocked(api.lanes.get)
.mockReset()
.mockResolvedValue({ lane: laneFixture({ status: "idle" }) });
vi.mocked(api.lanes.profileInit)
.mockReset()
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
});
describe("AddLaneModal", () => {
it("disables confirm until a repo, a title, and a resolved branch list are all present", () => {
describe("AddLaneModal — worktree mode (default)", () => {
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
renderModal();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
@@ -100,7 +151,9 @@ describe("AddLaneModal", () => {
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await waitFor(() => expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo"));
await waitFor(() =>
expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo")
);
const base = await screen.findByLabelText("Branch to fork from");
expect(base).toHaveValue("main"); // the repo's current branch is preselected
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
@@ -121,7 +174,7 @@ describe("AddLaneModal", () => {
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
it("submits through the worktree provisioning endpoint, not ensure", async () => {
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
const onAdded = vi.fn();
@@ -129,11 +182,7 @@ describe("AddLaneModal", () => {
renderModal({ onClose, onAdded });
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "New feature");
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => {
@@ -141,10 +190,16 @@ describe("AddLaneModal", () => {
sourceRepo: "/Users/tester/projects/repo",
title: "New feature",
base: "main",
branch: "feat/new-feature",
pipeline: "default",
});
});
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: "provisioning" }));
expect(onClose).toHaveBeenCalled();
expect(onAdded).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, status: "provisioning" })
);
// The modal stays open showing the setup summary until dismissed - it
// does not close itself just because the lane was added.
expect(onClose).not.toHaveBeenCalled();
});
it("shows a server error and leaves the modal open instead of closing silently", async () => {
@@ -154,11 +209,7 @@ describe("AddLaneModal", () => {
renderModal({ onClose });
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "New feature");
await fillWorktreeForm(user, { title: "New feature" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
@@ -169,4 +220,222 @@ describe("AddLaneModal", () => {
renderModal({ open: false });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
});
const onAdded = vi.fn();
renderModal({ onAdded });
const user = userEvent.setup();
await fillWorktreeForm(user);
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
expect(api.lanes.agentsInstall).toHaveBeenCalledWith(42);
expect(api.lanes.mcpSync).toHaveBeenCalledWith(42);
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("still calls onAdded even when every setup call fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
});
vi.mocked(api.lanes.profileInit).mockRejectedValue(new Error("boom"));
vi.mocked(api.lanes.agentsInstall).mockRejectedValue(new Error("boom"));
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo2" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(onClose).not.toHaveBeenCalled();
});
it("shows the setup summary and lets the user dismiss it manually", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
});
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo3" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
expect(await screen.findByText("Setup")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
if (!dismissButton) throw new Error("Cancel button not found");
await user.click(dismissButton);
expect(onClose).toHaveBeenCalled();
});
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
});
// First poll still provisioning, second poll reports the background
// `git worktree add` failed.
vi.mocked(api.lanes.get)
.mockReset()
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
const onAdded = vi.fn();
renderModal({ onAdded });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo4" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
expect(api.lanes.profileInit).not.toHaveBeenCalled();
expect(api.lanes.agentsInstall).not.toHaveBeenCalled();
expect(api.lanes.mcpSync).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — repo mode (adopt)", () => {
it("hides the branch fields and enables confirm on a path alone", async () => {
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
expect(screen.queryByLabelText("New branch name")).toBeNull();
expect(api.lanes.branches).not.toHaveBeenCalled();
});
it("submits through ensure, not worktree, and closes immediately", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
created: true,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
await user.type(screen.getByLabelText("Title"), "main repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith({
cwd: "/Users/tester/projects/repo",
title: "main repo",
pipeline: "default",
})
);
expect(api.lanes.worktree).not.toHaveBeenCalled();
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 23 })));
// Repo mode never runs the profile/agents/mcp setup summary - it should
// close right away like the old adopt flow did.
expect(onClose).toHaveBeenCalled();
});
});
describe("AddLaneModal — folder browse", () => {
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: "/Users",
entries: [{ name: "projects", path: "/Users/tester/projects", isGitRepo: false }],
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
expect(await screen.findByText("projects")).toBeInTheDocument();
await user.click(screen.getByText("projects"));
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
});
it("Escape closes only the folder browser, not the whole modal", async () => {
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: null,
entries: [],
});
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
await screen.findByRole("dialog", { name: "Browse for a folder" });
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect(
screen.getByRole("dialog", { name: "Create a lane from a working directory" })
).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — pipeline template", () => {
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
// Creation is the ONLY point the UI can set a template, so a lane born on
// `default` renders 8 nodes for a 16-node workflow with no way back from
// any screen.
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const select = await screen.findByLabelText("Pipeline template");
await waitFor(() =>
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
);
expect(
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
).toBeInTheDocument();
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.selectOptions(select, "ship-feature");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(
expect.objectContaining({ pipeline: "ship-feature" })
)
);
});
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
renderModal();
const select = await screen.findByLabelText("Pipeline template");
await waitFor(() => expect(select).toHaveValue("default"));
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
});
});
@@ -21,6 +21,10 @@ import { DestructiveLaneModal } from "../DestructiveLaneModal";
import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types";
function makeLane(overrides: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
// base object below still lists all of them, so the cast asserts what the
// literal already proves.
return {
id: 1,
title: "demo",
@@ -47,8 +51,10 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
...overrides,
};
} as Lane;
}
function worktreePreflight(overrides: Partial<LaneWorktreePreflight> = {}): LaneWorktreePreflight {
@@ -9,30 +9,56 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard from "../LaneCard";
import type { Lane } from "../../../lib/types";
import type { Lane, LaneRuntime } from "../../../lib/types";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: {
lanes: { git: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }) },
lanes: {
git: vi.fn(),
runtime: vi.fn(),
up: vi.fn(),
down: vi.fn(),
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
integration: vi.fn(),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
syncBaseCheck: vi.fn(),
},
locks: {
list: vi.fn(),
},
},
}));
beforeEach(() => {
vi.mocked(api.lanes.git).mockReset();
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
// A lane with no profile is the default fixture: most lanes never run a
// stack, so the runtime strip and its button stay absent unless a test opts in.
vi.mocked(api.lanes.runtime).mockReset();
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
vi.mocked(api.lanes.integration).mockReset();
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
});
vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
function makeLane(overrides: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
// base object below still lists all of them, so the cast asserts what the
// literal already proves.
return {
id: 1,
title: "demo",
cwd: "/work/demo",
branch: "lane/demo",
kind: "adopted",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
@@ -53,8 +79,10 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
...overrides,
};
} as Lane;
}
describe("LaneCard status badge", () => {
@@ -67,6 +95,31 @@ describe("LaneCard status badge", () => {
}
});
describe("LaneCard child worktrees", () => {
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
const onSelectLane = vi.fn();
const worktree = makeLane({ id: 8, title: "Worktree A", status: "running" });
render(
<LaneCard
lane={makeLane({ id: 1 })}
onAction={vi.fn()}
childWorktrees={[worktree]}
onSelectLane={onSelectLane}
/>
);
expect(screen.getByTestId("lane-child-worktrees")).toBeInTheDocument();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /Worktree A/ }));
expect(onSelectLane).toHaveBeenCalledWith(8);
});
it("renders nothing when there are no child worktrees", () => {
render(<LaneCard lane={makeLane({ id: 1 })} onAction={vi.fn()} />);
expect(screen.queryByTestId("lane-child-worktrees")).not.toBeInTheDocument();
});
});
const pipelineNodes: Lane["pipeline_nodes"] = [
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
@@ -225,3 +278,206 @@ describe("LaneCard git block", () => {
}
});
});
describe("LaneCard — the lane's own application stack", () => {
const upRuntime = {
available: true as const,
provisioned: true as const,
slot: 3,
kind: "managed" as const,
hooks: ["boot", "health"],
profileDir: "/work/demo/.ccam/profile",
services: [{ name: "web", pid: 4242, alive: true }],
ports: { api: { port: 8003, expected: 8003, listening: true } },
steppedAside: false,
up: true,
healthy: true,
logs: ["boot.log"],
logDir: "/lanes/.state/lane3/logs",
lastError: null,
};
it("shows nothing at all for a lane whose repo declares no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
render(<LaneCard lane={makeLane({ title: "no profile" })} onAction={vi.fn()} />);
expect(await screen.findByText("no profile")).toBeInTheDocument();
expect(screen.queryByTestId("lane-runtime-1")).toBeNull();
expect(screen.queryByTestId("lane-runtime-toggle")).toBeNull();
});
it("lists each declared port with its listening state", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-runtime-1")).toBeInTheDocument();
expect(screen.getByText(":8003")).toBeInTheDocument();
expect(screen.getByTestId("lane-runtime-state")).toHaveTextContent(/healthy/i);
});
it("flags a port that stepped aside from its base, showing the expected number", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
...upRuntime,
steppedAside: true,
ports: { api: { port: 8103, expected: 8003, listening: true } },
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(await screen.findByText(":8103")).toBeInTheDocument();
expect(screen.getByText(/8003/)).toBeInTheDocument();
});
it("stops the stack through /down, never through the run's onAction prop", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
vi.mocked(api.lanes.down).mockResolvedValue({
ok: true,
killed: [4242],
runtime: { available: false },
});
const onAction = vi.fn();
render(<LaneCard lane={makeLane()} onAction={onAction} />);
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
await waitFor(() => expect(api.lanes.down).toHaveBeenCalledWith(1));
expect(api.lanes.up).not.toHaveBeenCalled();
expect(onAction).not.toHaveBeenCalled();
});
it("boots a provisioned-but-down lane and stays busy past the 202", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
...upRuntime,
services: [{ name: "web", pid: 4242, alive: false }],
ports: { api: { port: 8003, expected: 8003, listening: false } },
up: false,
healthy: false,
});
vi.mocked(api.lanes.up).mockResolvedValue({ ok: true, laneId: 1 });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
await waitFor(() => expect(api.lanes.up).toHaveBeenCalledWith(1));
// The request resolving is not the stack being up: the server answered 202
// and is still booting, so the button must not go idle yet.
await waitFor(() => expect(screen.getByTestId("lane-runtime-toggle")).toBeDisabled());
});
it("surfaces the last boot error when nothing is streaming", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
...upRuntime,
up: false,
healthy: false,
lastError: { at: "2026-08-03T00:00:00Z", code: "EUNHEALTHY", message: "health check failed" },
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(await screen.findByText(/EUNHEALTHY/)).toBeInTheDocument();
expect(screen.getByText(/health check failed/)).toBeInTheDocument();
});
});
describe("LaneCard — named locks", () => {
it("shows a lock badge when this lane holds a named lock", async () => {
vi.mocked(api.locks.list).mockResolvedValue({
locks: [{ name: "build", holder: "lane1", since: 0, ageSec: 120 }],
});
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-locks-1")).toBeInTheDocument();
expect(screen.getByText(/lock/i)).toBeInTheDocument();
});
it("shows no lock badge when locks belong to a different lane", async () => {
vi.mocked(api.locks.list).mockResolvedValue({
locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }],
});
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
});
});
describe("agents install / mcp sync / integration badges / sync check", () => {
beforeEach(() => {
// Only the fields this describe block's assertions read; `as LaneRuntime`
// keeps the double from having to restate a shape the component never
// touches here.
vi.mocked(api.lanes.runtime).mockResolvedValue({
available: true as const,
provisioned: true as const,
up: false,
slot: 1,
profileDir: "/work/demo/.ccam/profile",
ports: {},
} as unknown as LaneRuntime);
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
Promise.resolve({ enabled: name === "tracker" })
);
});
it("shows integration badges reflecting each toggle's state", async () => {
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const tracker = await screen.findByTestId("lane-integration-tracker");
const devQc = await screen.findByTestId("lane-integration-dev_qc");
expect(tracker.className).toContain("status-success");
expect(devQc.className).not.toContain("status-success");
});
it("clicking Install agents calls the API and shows the result", async () => {
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
installed: ["qc-local.md", "senior-gate-reviewer.md"],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-agents-install");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
});
it("clicking Sync MCP calls the API and shows the result", async () => {
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
servers: ["playwright"],
profilesSeeded: [],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-mcp-sync");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
});
it("clicking Check dev sync reports a clean result", async () => {
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
code: 0,
devDelta: ["a.txt", "b.txt"],
overlap: [],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-sync-check");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
});
it("clicking Check dev sync reports a migration collision", async () => {
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
code: 5,
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const button = await screen.findByTestId("lane-sync-check");
await userEvent.click(button);
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
});
it("hides all four additions when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
expect(screen.queryByTestId("lane-agents-install")).toBeNull();
expect(screen.queryByTestId("lane-mcp-sync")).toBeNull();
expect(screen.queryByTestId("lane-sync-check")).toBeNull();
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
});
});
@@ -0,0 +1,465 @@
/**
* @file LaneConsolePane.tsx
* @description One lane's run console: the RunSetup TerminalView switcher,
* moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of
* these side by side (split terminal view). Owns its own prompt/cwd/model/
* permissionMode/effort/resumeSession/handle/busy/runHistory state nothing
* is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`,
* `activeRuns`, and `externalSessions` are supplied as props because they are global, not
* lane-specific, and fetching them per pane would mean N redundant identical
* requests for an N-pane layout.
*
* That state is bound to the lane the pane currently shows: switching `laneId`
* swaps the whole pane over to the new lane its cwd, its history, and its
* live tmux session instead of leaving the previous lane's terminal on
* screen under a new lane's header.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Play, AlertCircle } from "lucide-react";
import { api } from "../../lib/api";
import type {
CwdSuggestion,
DashboardRunHistoryItem,
EffortLevel,
PermissionMode,
RunHandle,
RunListResponse,
RunStartArgs,
} from "../../lib/api";
import type { Session, Lane } from "../../lib/types";
import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory";
export interface LaneConsolePaneProps {
lanes: Lane[];
laneId: number | null;
showLaneSelector: boolean;
onLaneIdChange: (id: number) => void;
onLaneCreated: (lane: Lane) => void;
binaryStatus: { found: boolean; path: string | null } | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null;
/** Active Claude Code sessions started outside the dashboard, listed in the
* active-runs switcher alongside dashboard runs. */
externalSessions?: Session[];
wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
}
export function LaneConsolePane({
lanes,
laneId,
showLaneSelector,
onLaneIdChange,
onLaneCreated,
binaryStatus,
cwdSuggestions,
activeRuns,
externalSessions,
wsConnected,
defaultCwd,
onHasActiveRunChange,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
const { t: tCommon } = useTranslation("common");
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
const [effort, setEffort] = useState<EffortLevel>("");
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
const [resumeSession, setResumeSession] = useState<Session | null>(null);
const [handle, setHandle] = useState<RunHandle | null>(null);
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
}, [handle, onHasActiveRunChange]);
useEffect(() => {
if (!currentLane && defaultCwd && cwd === "") {
setCwd(defaultCwd);
}
}, [defaultCwd, currentLane, cwd]);
const refreshList = useCallback(() => {
if (laneId !== null) {
api.run
.history(50, { laneId })
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
} else {
api.run
.history(50)
.then((r) => setRunHistory(r.items))
.catch(() => undefined);
}
}, [laneId]);
// `lanes` and `activeRuns` are re-fetched every few seconds by the page, so
// reading them through a ref keeps the lane-switch effect below off their
// identity — a background poll must not wipe a half-typed prompt.
const latest = useRef({ lanes, activeRuns, defaultCwd });
latest.current = { lanes, activeRuns, defaultCwd };
// A pane's prompt, cwd, history and terminal all belong to the lane it
// shows, so switching lanes has to swap every one of them. Re-attach right
// away when the new lane already has a live run: each lane sticks to its own
// tmux session, and the switch should land on that session rather than on an
// empty setup form the user then has to Start out of.
const autoAttachedForLane = useRef<number | null>(null);
useEffect(() => {
const { activeRuns: runs } = latest.current;
setPrompt("");
setResumeSession(null);
setError(null);
setBusy(null);
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
autoAttachedForLane.current = null;
refreshList();
}, [laneId, refreshList]);
// The switch effect above only sees whatever `activeRuns` the page already
// had loaded at that instant. Remounting this page (navigating away and
// back) starts `activeRuns` at null again, so a lane with a live run would
// otherwise show the setup form until the user switched lanes and back —
// the only path that re-ran the effect after the poll caught up. Re-check
// once `activeRuns` actually arrives, but only once per lane so it never
// fights a user-initiated "New Run".
useEffect(() => {
if (handle || laneId === null) return;
if (autoAttachedForLane.current === laneId) return;
const running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
if (running) {
autoAttachedForLane.current = laneId;
setHandle(running);
}
}, [activeRuns, laneId, handle]);
// The cwd tracks the lane's own folder separately, keyed on the resolved
// path rather than on `laneId` alone: the pane can mount before the lane
// list has loaded (split view restores its pane lanes from localStorage),
// and `laneId` never changes afterwards, so a laneId-only effect would leave
// the console pointing at the default directory. RunSetup submits this
// string verbatim, so a stale one starts the run in the wrong folder.
const laneCwd = currentLane?.cwd ?? null;
useEffect(() => {
if (laneCwd) setCwd(laneCwd);
else if (laneId === null) setCwd(latest.current.defaultCwd ?? "");
}, [laneId, laneCwd]);
const attachToRun = useCallback(
async (id: string) => {
if (busy) return;
setBusy("attach");
setError(null);
try {
const fetched = await api.run.get(id);
setHandle(fetched);
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.attachFailed", { message: m }));
} finally {
setBusy(null);
}
},
[busy, t]
);
const onStartFromSetup = useCallback(
async (args: RunStartArgs) => {
if (busy) return;
setBusy("start");
setError(null);
try {
const effectiveCwd = args.cwd || undefined;
if (!effectiveCwd) {
throw new Error(t("errors.cwdRequired"));
}
// Resolve the lane from the cwd the user actually typed, not from
// args.laneId — RunSetup always supplies this pane's laneId (a
// required prop), which would otherwise silently start a run in the
// wrong lane whenever the user types a cwd different from the one
// this pane currently shows.
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
let targetLaneId: number;
if (ownedLane) {
targetLaneId = ownedLane.id;
if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id);
} else {
try {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
onLaneIdChange(ensureResult.lane.id);
onLaneCreated(ensureResult.lane);
} catch (err) {
throw new Error(
t("errors.laneCreateFailed", {
message: err instanceof Error ? err.message : "unknown",
})
);
}
}
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: args.initialPrompt || "",
model: args.model || undefined,
permissionMode: args.permissionMode,
resumeSessionId: args.resumeSessionId,
effort: args.effort || undefined,
});
} catch (laneErr: unknown) {
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
await attachToRun(updatedLane.run_id);
return;
}
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) {
throw new Error(t("errors.noRunIdReturned"));
}
try {
const fetched = await api.run.get(laneStartResult.lane.run_id);
setHandle(fetched);
refreshList();
} catch {
try {
await attachToRun(laneStartResult.lane.run_id);
refreshList();
} catch (fallbackErr: unknown) {
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg }));
}
}
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.startFailed", { message: m }));
} finally {
setBusy(null);
}
},
[busy, t, lanes, laneId, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
);
const onResumeFromHistory = useCallback(
async (item: DashboardRunHistoryItem) => {
if (!item.session_id) return;
if (busy) return;
setBusy("start");
setError(null);
try {
let fetched: RunHandle;
if (item.cwd) {
const effectiveCwd = item.cwd;
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id;
if (!targetLaneId) {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
targetLaneId = ensureResult.lane.id;
onLaneCreated(ensureResult.lane);
}
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
prompt: "",
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
if (!laneStartResult.lane?.run_id) {
throw new Error("No run_id returned from lane start");
}
fetched = await api.run.get(laneStartResult.lane.run_id);
onLaneIdChange(targetLaneId);
} else {
fetched = await api.run.start({
laneId: 0,
initialPrompt: "",
cwd: undefined,
model: item.model || undefined,
permissionMode: item.permission_mode || undefined,
effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
}
setHandle(fetched);
setResumeSession(null);
refreshList();
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
setError(t("errors.startFailed", { message: msg }));
} finally {
setBusy(null);
}
},
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
);
const onViewFromHistory = useCallback(
(item: DashboardRunHistoryItem) => {
if (item.session_id) void onResumeFromHistory(item);
},
[onResumeFromHistory]
);
const newRun = useCallback(() => {
setHandle(null);
setPrompt("");
setResumeSession(null);
setError(null);
}, []);
if (laneId === null && showLaneSelector) {
return (
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
<select
data-testid="pane-lane-select"
aria-label={tLanes("splitView.paneLaneLabel")}
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
value=""
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
>
<option value="">{tLanes("splitView.pickLane")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
<p className="text-xs text-fg-muted">{tLanes("splitView.emptyPane")}</p>
</div>
);
}
return (
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
{showLaneSelector && (
<select
data-testid="pane-lane-select"
aria-label={tLanes("splitView.paneLaneLabel")}
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
value={laneId ?? ""}
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
>
<option value="">{tLanes("splitView.pickLane")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
)}
<header className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
<Play className="w-4.5 h-4.5 text-accent" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
</div>
<ActiveRunsSwitcher
activeRuns={activeRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
runHistory={runHistory}
externalSessions={externalSessions}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={refreshList}
/>
</header>
{binaryStatus && !binaryStatus.found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("binary.missing")}</span>
</div>
)}
{error && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 break-all">{error}</span>
</div>
)}
{!handle ? (
<RunSetup
laneId={laneId ?? 0}
prompt={prompt}
onPromptChange={setPrompt}
cwd={cwd}
onCwdChange={setCwd}
cwdSuggestions={cwdSuggestions}
model={model}
onModelChange={setModel}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
effort={effort}
onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true}
busy={busy === "start"}
onStart={onStartFromSetup}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
/>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<TerminalView
runId={handle!.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
/>
<button
onClick={newRun}
className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors"
>
{t("actions.newRun")}
</button>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+115 -45
View File
@@ -12,9 +12,16 @@
* status / mode chip filters, a free-text search, and the per-row Attach /
* Resume / View actions.
*
* Props only: no API call of its own. The page passes `activeRuns` and
* `runHistory` in and gets attach / resume / view / refresh back out through
* callbacks; the 2 s refresh ticker the modal runs just calls `onRefresh`.
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
* "Active runs" counts everything actually running. They carry no tmux session
* the dashboard can attach to, so their only action is Resume, which spawns a
* fresh tmux-backed `claude --resume <session>` in that cwd.
*
* Props only: no API call of its own. The page passes `activeRuns`,
* `runHistory` and `externalSessions` in and gets attach / resume / view /
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
* just calls `onRefresh`.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
@@ -33,8 +40,28 @@ import {
RotateCcw,
Eye,
} from "lucide-react";
import type { DashboardRunHistoryItem, RunListResponse, RunMode, RunStatus } from "../../lib/api";
import { ModeBadge, StatusPill } from "./RunConsole";
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
import type { Session } from "../../lib/types";
// Minimal StatusPill component (from deleted RunConsole)
function StatusPill({
status,
}: {
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
}) {
const colors: Record<string, string> = {
running: "bg-status-success/10 text-status-success border-status-success/30",
gone: "bg-surface-3 text-fg-secondary border-border",
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
killed: "bg-surface-3 text-fg-secondary border-border",
abandoned: "bg-surface-3 text-fg-secondary border-border",
};
const color = colors[status] || colors.abandoned;
return (
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{status}</span>
);
}
type RunStatusFilter =
| "all"
@@ -44,19 +71,21 @@ type RunStatusFilter =
| "error"
| "killed"
| "abandoned";
type RunModeFilter = "all" | "conversation" | "headless";
export interface UnifiedRunRow {
id: string;
sessionId: string | null;
mode: RunMode;
cwd: string;
model: string | null;
status: RunStatus;
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
promptPreview: string;
startedAt: number;
endedAt: number | null;
isLive: boolean;
/** Live Claude Code session this dashboard did not spawn no tmux session to
* attach to, so Resume (a fresh `claude --resume` in its cwd) is the only
* action. */
external?: boolean;
}
export function ActiveRunsSwitcher({
@@ -64,6 +93,7 @@ export function ActiveRunsSwitcher({
currentHandleId,
onAttach,
runHistory,
externalSessions = [],
onResumeFromHistory,
onViewFromHistory,
onRefresh,
@@ -72,6 +102,9 @@ export function ActiveRunsSwitcher({
currentHandleId: string | null;
onAttach: (id: string) => void;
runHistory: DashboardRunHistoryItem[];
/** Sessions with `status: "active"` from GET /api/sessions. Remote-source and
* cwd-less sessions are ignored neither can be resumed on this machine. */
externalSessions?: Session[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
onRefresh: () => void;
@@ -94,37 +127,40 @@ export function ActiveRunsSwitcher({
};
}, [open]);
// Merge live in-memory handles + persistent history into one row list.
// Live entries dedupe past-history entries with the same id.
const rows: UnifiedRunRow[] = useMemo(() => {
// Merge live in-memory handles + persistent history + externally started
// sessions into one row list. Live entries dedupe past-history entries with
// the same id; a session id already covered by a run row is never repeated as
// an external row.
const { rows, historyItems } = useMemo(() => {
const out: UnifiedRunRow[] = [];
const seen = new Set<string>();
const seenSessions = new Set<string>();
if (activeRuns) {
for (const r of activeRuns.items) {
seen.add(r.id);
if (r.sessionId) seenSessions.add(r.sessionId);
out.push({
id: r.id,
sessionId: r.sessionId,
mode: r.mode,
cwd: r.cwd,
cwd: r.cwd || "",
model: r.model,
status: r.status,
promptPreview: r.prompt || "",
startedAt: r.startedAt,
endedAt: r.endedAt,
isLive: r.status === "running" || r.status === "spawning",
promptPreview: r.promptPreview || "",
startedAt: r.startedAt ? new Date(r.startedAt).getTime() : 0,
endedAt: null,
isLive: r.status === "running",
});
}
}
for (const h of runHistory) {
if (seen.has(h.id)) continue;
seen.add(h.id);
if (h.session_id) seenSessions.add(h.session_id);
const startedTs = new Date(h.started_at).getTime() || 0;
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
out.push({
id: h.id,
sessionId: h.session_id,
mode: h.mode,
cwd: h.cwd,
model: h.model,
status: h.status,
@@ -134,11 +170,51 @@ export function ActiveRunsSwitcher({
isLive: h.isLive,
});
}
// Externally started sessions: shown as live rows, and mirrored as
// synthetic history items so the existing resume path (which only reads
// session_id / cwd / model) works on them unchanged.
const synthetic: DashboardRunHistoryItem[] = [];
for (const s of externalSessions) {
if (!s.cwd) continue;
if (s.source && s.source !== "local") continue;
if (seenSessions.has(s.id)) continue;
seenSessions.add(s.id);
synthetic.push({
id: `session:${s.id}`,
session_id: s.id,
cwd: s.cwd,
model: s.model,
permission_mode: null,
effort: null,
resume_session_id: null,
prompt_preview: s.name,
status: "running",
exit_code: null,
started_at: s.started_at,
ended_at: null,
isLive: true,
});
out.push({
id: `session:${s.id}`,
sessionId: s.id,
cwd: s.cwd,
model: s.model,
status: "running",
promptPreview: s.name || "",
startedAt: new Date(s.started_at).getTime() || 0,
endedAt: null,
isLive: true,
external: true,
});
}
out.sort((a, b) => b.startedAt - a.startedAt);
return out;
}, [activeRuns, runHistory]);
return {
rows: out,
historyItems: synthetic.length ? [...runHistory, ...synthetic] : runHistory,
};
}, [activeRuns, runHistory, externalSessions]);
const liveCount = activeRuns?.activeCount ?? 0;
const liveCount = rows.filter((r) => r.isLive).length;
const totalCount = rows.length;
return (
@@ -181,7 +257,7 @@ export function ActiveRunsSwitcher({
setOpen(false);
onViewFromHistory(item);
}}
runHistory={runHistory}
runHistory={historyItems}
onClose={() => setOpen(false)}
onRefresh={onRefresh}
/>
@@ -211,7 +287,6 @@ export function RunsModal({
}) {
const { t } = useTranslation("run");
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all");
const [modeFilter, setModeFilter] = useState<RunModeFilter>("all");
const [search, setSearch] = useState("");
// Snappy refresh while the modal is the foreground UI: pull immediately
@@ -227,25 +302,22 @@ export function RunsModal({
const counts = useMemo(() => {
const byStatus: Record<string, number> = { all: rows.length };
const byMode: Record<string, number> = { all: rows.length };
for (const r of rows) {
byStatus[r.status] = (byStatus[r.status] || 0) + 1;
byMode[r.mode] = (byMode[r.mode] || 0) + 1;
}
return { byStatus, byMode };
return { byStatus };
}, [rows]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return rows.filter((r) => {
if (statusFilter !== "all" && r.status !== statusFilter) return false;
if (modeFilter !== "all" && r.mode !== modeFilter) return false;
if (!q) return true;
const hay =
r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || "");
return hay.toLowerCase().includes(q);
});
}, [rows, statusFilter, modeFilter, search]);
}, [rows, statusFilter, search]);
const historyById = useMemo(() => {
const m = new Map<string, DashboardRunHistoryItem>();
@@ -261,7 +333,6 @@ export function RunsModal({
"killed",
"abandoned",
];
const MODES: RunModeFilter[] = ["all", "conversation", "headless"];
return (
<div
@@ -343,16 +414,6 @@ export function RunsModal({
}))}
onChange={(v) => setStatusFilter(v as RunStatusFilter)}
/>
<FilterChipGroup
label={t("runs.filterMode", "Mode")}
value={modeFilter}
options={MODES.map((m) => ({
value: m,
label: m === "all" ? t("runs.allLabel", "All") : t(`mode.${m}`),
count: counts.byMode[m] || 0,
}))}
onChange={(v) => setModeFilter(v as RunModeFilter)}
/>
</div>
</div>
@@ -467,10 +528,11 @@ function UnifiedRunRowView({
hour: "2-digit",
minute: "2-digit",
});
const canResume = row.mode === "conversation" && !!row.sessionId && !row.isLive;
// Headless runs are single-shot, so resume doesn't apply - but the captured
// transcript is still worth viewing. Link to the Session detail page.
const canView = row.mode === "headless" && !!row.sessionId && !row.isLive;
// Without mode distinction, offer resume for any finished run with a session.
// An external session is live but has no attachable tmux session, so Resume
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
const canResume = !!row.sessionId && (!row.isLive || !!row.external);
const canView = !!row.sessionId && !row.isLive;
return (
<div
className={`px-5 py-3 transition-colors ${
@@ -479,20 +541,27 @@ function UnifiedRunRowView({
>
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<StatusPill status={row.status} />
<ModeBadge mode={row.mode} />
{row.isLive && (
<span className="text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("runs.liveBadge", "live")}
</span>
)}
{row.external && (
<span
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
title={t("runs.externalHint")}
>
{t("runs.externalBadge")}
</span>
)}
{isCurrent && (
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
{t("runs.currentBadge", "current")}
</span>
)}
<span className="ml-auto inline-flex items-center gap-1.5">
{row.isLive && !isCurrent && (
{row.isLive && !row.external && !isCurrent && (
<button
onClick={onAttach}
className="inline-flex items-center gap-1 rounded-md border border-status-success/40 bg-status-success/10 hover:bg-status-success/20 text-status-success px-2 py-0.5 text-[10.5px] font-medium transition-colors"
@@ -504,6 +573,7 @@ function UnifiedRunRowView({
{canResume && (
<button
onClick={onResume}
title={row.external ? t("runs.externalHint") : undefined}
className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-2 py-0.5 text-[10.5px] font-medium transition-colors"
>
<RotateCcw className="w-3 h-3" />
+90 -62
View File
@@ -5,14 +5,15 @@
* the Run page and the Workspace page can both mount the same panel.
*
* What lives here:
* - `RunSetup` mode (conversation / headless), fresh-vs-resume source, the
* prompt editor, and the cwd / model / permission-mode / effort fields,
* plus the concurrency hint and the Start button. Its disabled state is
* driven by the `binaryFound` prop, so a missing `claude` binary is a
* surfaced state here rather than a probe of its own.
* above the panel, with its own localStorage-persisted minimized state.
* - `RunSetup` fresh-vs-resume source, the prompt editor, and the cwd /
* model / permission-mode / effort fields, plus the concurrency hint and
* the Start button. Its disabled state is driven by the `binaryFound` prop,
* so a missing `claude` binary is a surfaced state here rather than a probe
* of its own. Picking a session to resume starts that run immediately
* a resume carries its own history, so there is nothing to type first;
* the prompt stays optional for resumes and required for fresh runs.
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
* `ModelPicker`, and the small `ModeOption` / `Field` layout helpers.
* `ModelPicker`, and the small `Field` layout helper.
*
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
* lifecycle the page owns `api.run.start` and hands the result back through
@@ -45,18 +46,53 @@ import type {
RunListResponse,
EffortLevel,
PermissionMode,
RunMode,
RunStartArgs,
} from "../../lib/api";
import type { Session } from "../../lib/types";
import { Select } from "../Select";
import { PromptEditor } from "./RunConsole";
import type { SlashCommand } from "./RunConsole";
// Minimal SlashCommand type (from deleted RunConsole)
export interface SlashCommand {
name: string;
source: "project" | "user" | "plugin" | "builtin";
description?: string;
}
// Minimal PromptEditor component (from deleted RunConsole)
interface PromptEditorProps {
value: string;
onChange: (s: string) => void;
onSubmit: () => void;
placeholder: string;
rows?: number;
slashCommands: SlashCommand[];
fileCwd: string;
}
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
onSubmit();
}
};
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
/>
);
}
// ── Limitations banner (above the config card) ────────────────────────
interface RunSetupProps {
mode: RunMode;
onModeChange: (m: RunMode) => void;
laneId: number;
prompt: string;
onPromptChange: (s: string) => void;
cwd: string;
@@ -70,7 +106,7 @@ interface RunSetupProps {
onEffortChange: (e: EffortLevel) => void;
binaryFound: boolean;
busy: boolean;
onStart: () => void;
onStart: (args: RunStartArgs) => void;
activeRuns: RunListResponse | null;
resumeSession: Session | null;
onResumeSessionChange: (s: Session | null) => void;
@@ -78,54 +114,27 @@ interface RunSetupProps {
* sessions. Undefined when no lane is selected (the picker then lists
* everything, same as before lanes existed). */
laneCwd?: string;
slashCommands: SlashCommand[];
slashCommands?: SlashCommand[];
runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
}
export function RunSetup(props: RunSetupProps) {
const { t } = useTranslation("run");
const atCap =
props.activeRuns != null && props.activeRuns.activeCount >= props.activeRuns.maxConcurrent;
const atCap = false; // TODO: re-add when concurrency info is available
const isResume = !!props.resumeSession;
const [resumePicked, setResumePicked] = useState(isResume);
// Keep "resume picked" in sync with the parent. Two cases:
// 1. Parent set a resume session (e.g. user clicked Resume in the runs
// modal) - flip the radio so the picker is shown and the selection
// is visible.
// 2. Parent cleared the session and mode flipped to headless - clear
// the radio so the form is honest.
// Keep "resume picked" in sync with the parent. Parent set a resume session
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
// is shown and the selection is visible.
useEffect(() => {
if (isResume && !resumePicked) setResumePicked(true);
else if (!isResume && resumePicked && props.mode === "headless") setResumePicked(false);
}, [isResume, resumePicked, props.mode]);
}, [isResume, resumePicked]);
return (
<div className="rounded-xl border border-border bg-surface-1">
{/* Mode and source on one line. Both are two-way choices made once at
spawn time, so a segmented row carries them; the longer explanations
live in each button's title rather than in a paragraph. */}
{/* Fresh vs resume source picker */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={props.mode === "conversation"}
label={t("mode.conversation")}
title={t("mode.conversationHint")}
onClick={() => props.onModeChange("conversation")}
/>
<Seg
active={props.mode === "headless"}
label={t("mode.headless")}
title={`${t("mode.headlessHint")}${t("hint.headlessExplain")}`}
onClick={() => {
props.onModeChange("headless");
setResumePicked(false);
}}
/>
</div>
{props.mode === "conversation" && (
<>
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={!resumePicked}
@@ -147,13 +156,19 @@ export function RunSetup(props: RunSetupProps) {
<div className="min-w-0 flex-1">
<SessionPicker
selected={props.resumeSession}
onSelect={props.onResumeSessionChange}
cwd={props.laneCwd}
onSelect={(s) => {
props.onResumeSessionChange(s);
// Pass the picked session explicitly: the parent's state
// update has not landed yet on this tick, so reading
// props.resumeSession here would resume nothing.
if (s && !props.busy) handleStart(props, s);
}}
// Fall back to the typed cwd when no lane is selected — otherwise
// an unfiltered picker lists every session from every repo.
cwd={props.laneCwd || props.cwd.trim() || undefined}
/>
</div>
)}
</>
)}
</div>
{/* Prompt */}
@@ -164,10 +179,10 @@ export function RunSetup(props: RunSetupProps) {
<PromptEditor
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={props.onStart}
placeholder={t("fields.promptPlaceholder")}
onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholderTerminal")}
rows={5}
slashCommands={props.slashCommands}
slashCommands={props.slashCommands ?? []}
fileCwd={props.resumeSession?.cwd || props.cwd}
/>
<div className="mt-1 text-[10px] text-fg-muted">
@@ -234,20 +249,16 @@ export function RunSetup(props: RunSetupProps) {
{atCap ? (
<span className="inline-flex items-center gap-1.5 text-status-warning">
<AlertCircle className="w-3.5 h-3.5" />
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })}
</span>
) : props.activeRuns && props.activeRuns.activeCount > 0 ? (
<span className="inline-flex items-center gap-1.5 text-fg-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
{t("concurrency.active", { count: props.activeRuns.activeCount })}
{t("concurrency.atCap", { max: 0 })}
</span>
) : null}
</div>
<button
onClick={props.onStart}
onClick={() => handleStart(props)}
disabled={
!props.binaryFound ||
!props.prompt.trim() ||
// A resume needs no prompt — the session it continues is the input.
(!props.resumeSession && !props.prompt.trim()) ||
props.busy ||
atCap ||
(resumePicked && !props.resumeSession) ||
@@ -270,6 +281,23 @@ export function RunSetup(props: RunSetupProps) {
);
}
/** `session` overrides `props.resumeSession` for the auto-start fired straight
* out of the picker, before the parent's state has caught up. */
function handleStart(props: RunSetupProps, session?: Session) {
const resume = session ?? props.resumeSession;
props.onStart({
laneId: props.laneId,
// A resume is pinned to its own session's folder — that's what the locked
// cwd field shows, so it's what gets sent.
cwd: resume?.cwd || props.cwd || undefined,
model: props.model || undefined,
permissionMode: props.permissionMode || undefined,
effort: props.effort || undefined,
resumeSessionId: resume?.id || undefined,
initialPrompt: props.prompt || undefined,
});
}
/** One segment of a two-way inline choice. The explanation rides on `title`
* instead of a hint line, which is what keeps the row to one line. */
function Seg({
@@ -0,0 +1,82 @@
/**
* @file TerminalView.tsx
* @description Renders one lane's live terminal a real `xterm.js` instance
* attached via WebSocket to the server's `/ws-pty/:runId` path (see
* server/lib/pty-attach.js), which is itself a `node-pty`-backed
* `tmux attach-session`. Binary WS frames are raw PTY bytes in both
* directions; a JSON text frame carries the initial `resize` on mount and
* the server's one-shot `exit` notice when the pane process ends.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
interface TerminalViewProps {
runId: string;
wsBaseUrl: string;
}
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const term = new Terminal({ convertEol: true, fontSize: 13, cursorBlink: true });
const fit = new FitAddon();
term.loadAddon(fit);
if (containerRef.current) term.open(containerRef.current);
fit.fit();
const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
// Server sends PTY bytes as binary frames — default binaryType ("blob")
// would hand onmessage a Blob that the string checks below never match,
// silently dropping all terminal output. "arraybuffer" keeps it sync.
ws.binaryType = "arraybuffer";
const decoder = new TextDecoder();
ws.onopen = () => {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
};
ws.onmessage = (event) => {
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer) : event.data;
if (typeof data === "string") {
// A JSON control frame is the only thing that starts with `{"type"`.
if (data.startsWith('{"type"')) {
try {
const msg = JSON.parse(data);
if (msg.type === "exit") {
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
}
return;
} catch {
/* not JSON — fall through and render as PTY output */
}
}
term.write(data);
}
};
const dataDisposable = term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) ws.send(data);
});
const resizeObserver = new ResizeObserver(() => {
fit.fit();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
}
});
if (containerRef.current) resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
dataDisposable.dispose();
ws.close();
term.dispose();
};
}, [runId, wsBaseUrl]);
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
}
@@ -0,0 +1,197 @@
/**
* @file LaneConsolePane.test.tsx
* @description Test suite for the LaneConsolePane component
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
vi.mock("../TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
vi.mock("../../../lib/api", () => ({
api: {
lanes: {
ensure: vi.fn(),
action: vi.fn(),
list: vi.fn(),
},
run: {
list: vi.fn().mockResolvedValue({ items: [] }),
history: vi.fn().mockResolvedValue({ items: [] }),
get: vi.fn(),
start: vi.fn(),
},
},
RUN_MODEL_CHOICES: [],
RUN_EFFORT_CHOICES: [],
}));
const LANE: Lane = {
id: 1,
title: "demo",
cwd: "/workspace/a",
branch: null,
kind: "adopted",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "idle",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Default",
pipeline_nodes: [],
progress: 0,
stage_seconds: null,
last_event_seconds: null,
liveness: "idle" as Lane["liveness"],
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
active_feature_id: null,
};
function baseProps() {
return {
lanes: [LANE],
laneId: 1,
showLaneSelector: false,
onLaneIdChange: vi.fn(),
onLaneCreated: vi.fn(),
binaryStatus: { found: true, path: "/usr/local/bin/claude" },
cwdSuggestions: [],
activeRuns: { items: [] },
wsConnected: true,
};
}
describe("LaneConsolePane", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("starts a run through /api/lanes/<id>/start, not /api/run/start", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "run-1" },
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-1",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
});
render(<LaneConsolePane {...baseProps()} />);
// Set cwd and prompt
const cwdInput = screen.getByPlaceholderText(/type to search/i);
fireEvent.change(cwdInput, { target: { value: "/workspace/a" } });
const promptTextarea = screen.getByPlaceholderText(/ask claude/i);
fireEvent.change(promptTextarea, { target: { value: "test prompt" } });
// Find and click the Run button (the main start button in RunSetup)
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", expect.any(Object))
);
expect(api.run.start).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-1")
);
});
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
const run = (id: string, laneId: number) => ({
id,
laneId,
status: "running" as const,
cwd: null,
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
});
const props = {
...baseProps(),
lanes: [LANE, LANE2],
activeRuns: { items: [run("ccam-lane-1", 1), run("ccam-lane-2", 2)] },
};
const { rerender } = render(<LaneConsolePane {...props} />);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-1")
);
rerender(<LaneConsolePane {...props} laneId={2} />);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-2")
);
// A lane with no live run falls back to its setup form, not the previous
// lane's terminal.
rerender(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />);
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
});
it("adopts the lane's cwd when the lane list arrives after the pane mounted", async () => {
// Split view restores its pane lanes from localStorage, so a pane can
// render with a laneId before GET /api/lanes has answered. laneId never
// changes afterwards — only the resolved lane does.
const props = { ...baseProps(), lanes: [], defaultCwd: "/home/tester" };
const { rerender } = render(<LaneConsolePane {...props} />);
const cwdInput = screen.getByPlaceholderText(/type to search/i) as HTMLInputElement;
expect(cwdInput.value).toBe("/home/tester");
rerender(<LaneConsolePane {...props} lanes={[LANE]} />);
await waitFor(() => expect(cwdInput.value).toBe(LANE.cwd));
});
it("shows a lane dropdown only when showLaneSelector is true", () => {
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
rerender(<LaneConsolePane {...baseProps()} showLaneSelector={false} />);
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
});
it("renders RunSetup when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
expect(screen.getByTestId("console-body")).toBeInTheDocument();
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
});
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
});
});
@@ -1,172 +0,0 @@
/**
* @file RunConsole.test.tsx
* @description Pins the props-only boundary of `RunConsole` after its move out
* of `pages/Run.tsx`: the envelope stream renders from the `envelopes` prop
* (no stream subscription of its own), the token meter rolls up usage from
* those same envelopes, the prompt editor's `/` autocomplete filters and fills
* the prompt through `onFollowUpChange`, and `onSend` / `onStop` fire from the
* send and stop controls.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { MemoryRouter } from "react-router-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RunConsole, type SlashCommand } from "../RunConsole";
import type { Envelope } from "../../../hooks/useRunStream";
import type { RunHandle } from "../../../lib/api";
const HANDLE: RunHandle = {
id: "run-1",
pid: 4242,
mode: "conversation",
cwd: "/tmp/project",
model: "claude-opus-5",
permissionMode: "acceptEdits",
effort: "",
prompt: "hi",
argv: [],
resumeSessionId: null,
status: "running",
startedAt: 1,
endedAt: null,
exitCode: null,
signal: null,
error: null,
sessionId: null,
envelopeCount: 0,
stdoutTail: "",
stderrTail: "",
};
const COMMANDS: SlashCommand[] = [
{ name: "code-review", description: "Review the working diff", source: "project" },
{ name: "compact", description: "Compact the conversation context", source: "builtin" },
{ name: "logout", description: "Sign out", source: "builtin" },
];
/**
* Mount the console with the parent-owned follow-up state it expects, so the
* autocomplete assertions exercise the real controlled-input round trip.
*/
function renderConsole(
props: Partial<React.ComponentProps<typeof RunConsole>> = {},
onFollowUp?: (s: string) => void
) {
const seen = { followUp: "" };
function Harness() {
const [followUp, setFollowUp] = useState("");
seen.followUp = followUp;
return (
<RunConsole
handle={HANDLE}
envelopes={[]}
mode="conversation"
isLive
hasFinished={false}
followUp={followUp}
onFollowUpChange={(s) => {
setFollowUp(s);
onFollowUp?.(s);
}}
busy={null}
onSend={() => {}}
onStop={() => {}}
onNewRun={() => {}}
slashCommands={COMMANDS}
{...props}
/>
);
}
render(
<MemoryRouter>
<Harness />
</MemoryRouter>
);
return seen;
}
describe("RunConsole", () => {
it("renders assistant text from the envelopes prop", () => {
const envelopes: Envelope[] = [
{ type: "user", message: { content: "explain this repo" } },
{ type: "assistant", message: { content: [{ type: "text", text: "Here is the answer." }] } },
] as Envelope[];
renderConsole({ envelopes });
expect(screen.getByText("explain this repo")).toBeInTheDocument();
expect(screen.getByText("Here is the answer.")).toBeInTheDocument();
});
it("shows the empty-stream placeholder when there are no envelopes", () => {
renderConsole({ isLive: false });
expect(screen.getByText("Nothing yet")).toBeInTheDocument();
});
it("shows the token totals computed from the envelopes", () => {
// Transcript-shaped assistant envelope (no `message.id`), which is the
// branch computeTokens folds into the running totals.
const envelopes: Envelope[] = [
{
type: "assistant",
message: {
content: [{ type: "text", text: "done" }],
usage: { input_tokens: 12_000, output_tokens: 2_500, cache_read_input_tokens: 8_000 },
},
},
] as Envelope[];
renderConsole({ envelopes });
// Context gauge: (input + cache read) / default 200k window.
// The CLI-style meter is one status line: context usage as a single label,
// then output and cache-hit figures with terminal glyphs. Input is implied
// by the context total rather than listed separately.
expect(screen.getByText("20.0k / 200k (10%)")).toBeInTheDocument();
expect(screen.getByText("↑2.5k")).toBeInTheDocument(); // Output
expect(screen.getByText("⚡8.0k")).toBeInTheDocument(); // Cache hit
});
it("filters slash commands as the user types and fills the prompt on pick", () => {
const seen = renderConsole();
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: "/co" } });
expect(screen.getByText("/code-review")).toBeInTheDocument();
expect(screen.getByText("/compact")).toBeInTheDocument();
expect(screen.queryByText("/logout")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("/code-review"));
expect(seen.followUp).toBe("/code-review");
expect(screen.queryByText("/compact")).not.toBeInTheDocument(); // dropdown closed
});
it("fires onSend from the send button with the prompt the parent holds", () => {
const onSend = vi.fn();
const seen = renderConsole({ onSend });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "follow up please" } });
fireEvent.click(screen.getByRole("button", { name: /send/i }));
expect(onSend).toHaveBeenCalledTimes(1);
expect(seen.followUp).toBe("follow up please");
});
it("fires onStop from the stop control while live, and hides it when not", () => {
const onStop = vi.fn();
renderConsole({ onStop });
fireEvent.click(screen.getByRole("button", { name: /stop/i }));
expect(onStop).toHaveBeenCalledTimes(1);
});
it("hides the stop control and the follow-up editor once the run is not live", () => {
renderConsole({ isLive: false, hasFinished: true });
expect(screen.queryByRole("button", { name: /stop/i })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
});
});
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
import type { Session } from "../../../lib/types";
const LIVE_ID = "run-live";
const PAST_ID = "run-past";
@@ -29,12 +30,11 @@ const activeRuns = {
{
id: LIVE_ID,
sessionId: "sess-live",
mode: "conversation",
cwd: "/tmp/live",
model: "claude-opus-5",
status: "running",
prompt: "the live prompt",
startedAt: 3000,
startedAt: "2000-01-01T00:50:00Z",
promptPreview: "the live prompt",
endedAt: null,
},
],
@@ -44,7 +44,6 @@ function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistor
return {
id: PAST_ID,
session_id: "sess-past",
mode: "conversation",
cwd: "/tmp/past",
model: "sonnet",
status: "completed",
@@ -64,12 +63,26 @@ const PAST = historyItem({});
const HEADLESS = historyItem({
id: HEADLESS_ID,
session_id: "sess-headless",
mode: "headless",
cwd: "/tmp/headless",
prompt_preview: "the headless prompt",
started_at: new Date(1000).toISOString(),
});
function externalSession(over: Partial<Session> = {}): Session {
return {
id: "sess-external",
name: "the external prompt",
status: "active",
cwd: "/tmp/external",
model: "claude-opus-5",
started_at: new Date(3000).toISOString(),
ended_at: null,
updated_at: new Date(3000).toISOString(),
source: "local",
...over,
} as unknown as Session;
}
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
const spies = {
onAttach: vi.fn(),
@@ -95,10 +108,9 @@ function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
return {
id,
sessionId: `sess-${id}`,
mode: "conversation",
cwd: `/tmp/${id}`,
model: "sonnet",
status: "completed",
status: "abandoned",
promptPreview: `prompt of ${id}`,
startedAt: 1000,
endedAt: 2000,
@@ -192,6 +204,44 @@ describe("ActiveRunsSwitcher", () => {
expect(screen.queryByText("stale copy")).toBeNull();
});
it("counts and lists a session started outside the dashboard, resumable not attachable", () => {
const { spies } = renderSwitcher({
activeRuns: null,
runHistory: [],
externalSessions: [externalSession()],
});
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
expect(screen.getByText("the external prompt")).toBeTruthy();
expect(screen.getByText(i18n.t("run:runs.externalBadge"))).toBeTruthy();
// No tmux session of ours to attach to — Resume is the only action.
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
expect(spies.onResumeFromHistory).toHaveBeenCalledWith(
expect.objectContaining({
id: "session:sess-external",
session_id: "sess-external",
cwd: "/tmp/external",
status: "running",
isLive: true,
})
);
});
it("skips external sessions already covered by a run, remote ones, and cwd-less ones", () => {
renderSwitcher({
runHistory: [],
externalSessions: [
externalSession({ id: "sess-live" }), // same session as the live run
externalSession({ id: "sess-remote", source: "remote-1" }),
externalSession({ id: "sess-nocwd", cwd: null }),
],
});
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
expect(screen.getByText("the live prompt")).toBeTruthy();
expect(screen.queryByText("the external prompt")).toBeNull();
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
});
it("fires attach with the run id of the row that was clicked", () => {
const { spies } = renderSwitcher();
openModal();
@@ -216,39 +266,31 @@ describe("RunsModal", () => {
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
});
it("fires resume with the history item behind a finished conversation row", () => {
it("fires resume with the history item behind a finished row", () => {
const { spies } = renderModal([row(PAST_ID)]);
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
expect(spies.onResume).toHaveBeenCalledWith(PAST);
expect(spies.onView).not.toHaveBeenCalled();
});
it("fires view — not resume — for a finished headless row", () => {
const { spies } = renderModal([row(HEADLESS_ID, { mode: "headless" })]);
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
it("fires view for a finished row", () => {
const { spies } = renderModal([row(HEADLESS_ID)]);
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
expect(spies.onResume).not.toHaveBeenCalled();
});
it("filters by status, by mode and by free text", () => {
it("filters by status and by free text", () => {
const rows = [
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
row("b", { status: "error", promptPreview: "bravo" }),
row("c", { status: "completed", mode: "headless", promptPreview: "charlie" }),
row("b", { status: "killed", promptPreview: "bravo" }),
row("c", { status: "abandoned", promptPreview: "charlie" }),
];
renderModal(rows);
fireEvent.click(chip(i18n.t("run:status.error")));
fireEvent.click(chip(i18n.t("run:status.killed")));
expect(screen.getByText("bravo")).toBeTruthy();
expect(screen.queryByText("alpha")).toBeNull();
fireEvent.click(allChip(0));
fireEvent.click(chip(i18n.t("run:mode.headless")));
expect(screen.getByText("charlie")).toBeTruthy();
expect(screen.queryByText("bravo")).toBeNull();
fireEvent.click(allChip(1));
fireEvent.change(
screen.getByPlaceholderText(
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
@@ -39,7 +39,6 @@ type Spies = ReturnType<typeof renderSetup>["spies"];
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
const spies = {
onModeChange: vi.fn(),
onPromptChange: vi.fn(),
onCwdChange: vi.fn(),
onModelChange: vi.fn(),
@@ -52,7 +51,7 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
const utils = render(
<MemoryRouter>
<RunSetup
mode="conversation"
laneId={1}
prompt="do the thing"
cwd="/Users/tester"
cwdSuggestions={SUGGESTIONS}
@@ -95,15 +94,6 @@ beforeEach(() => {
});
describe("RunSetup — selections report through callbacks", () => {
it("reports the mode from the one-shot / conversation options", () => {
const { spies } = renderSetup();
fireEvent.click(screen.getByText(i18n.t("run:mode.headless")));
expect(spies.onModeChange).toHaveBeenCalledWith("headless");
fireEvent.click(screen.getByText(i18n.t("run:mode.conversation")));
expect(spies.onModeChange).toHaveBeenLastCalledWith("conversation");
onlyCalled(spies, "onModeChange");
});
it("reports the prompt from the editor", () => {
const { spies } = renderSetup({ prompt: "" });
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
@@ -171,7 +161,7 @@ describe("RunSetup — missing binary and other blocked states", () => {
expect(runButton().disabled).toBe(false);
});
it("still disables Run without a prompt, without a cwd, or at the concurrency cap", () => {
it("still disables Run without a prompt or without a cwd", () => {
const { unmount } = renderSetup({ prompt: " " });
expect(runButton().disabled).toBe(true);
unmount();
@@ -179,12 +169,6 @@ describe("RunSetup — missing binary and other blocked states", () => {
const noCwd = renderSetup({ cwd: "" });
expect(runButton().disabled).toBe(true);
noCwd.unmount();
renderSetup({
activeRuns: { items: [], activeCount: 2, maxConcurrent: 2 } as never,
});
expect(runButton().disabled).toBe(true);
expect(screen.getByText(i18n.t("run:concurrency.atCap", { max: 2 }))).toBeTruthy();
});
it("shows the Starting… label while busy", () => {
@@ -217,9 +201,47 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
);
});
it("lists everything when no lane is selected", async () => {
it("starts the resume immediately when a session is picked", async () => {
const { api } = await import("../../../lib/api");
renderSetup({ laneCwd: undefined });
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
],
total: 1,
limit: 100,
offset: 0,
} as never);
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
expect(spies.onResumeSessionChange).toHaveBeenCalledWith(
expect.objectContaining({ id: "sess-in-lane" })
);
// No prompt typed, no Run click - the pick itself is the start, and it
// carries the session's own cwd rather than the form's.
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ resumeSessionId: "sess-in-lane", cwd: "/Users/tester/lane-a" })
);
});
it("falls back to the typed cwd when no lane is selected", async () => {
const { api } = await import("../../../lib/api");
renderSetup({ laneCwd: undefined, cwd: "/Users/tester" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
await new Promise((r) => setTimeout(r, 0));
expect(api.sessions.list).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/Users/tester" })
);
});
it("lists everything when no lane is selected and cwd is empty", async () => {
const { api } = await import("../../../lib/api");
renderSetup({ laneCwd: undefined, cwd: "" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
@@ -237,7 +259,7 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
rerender(
<MemoryRouter>
<RunSetup
mode="conversation"
laneId={1}
prompt="do the thing"
cwd="/Users/tester"
cwdSuggestions={SUGGESTIONS}
@@ -251,7 +273,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
slashCommands={[]}
runHistory={[]}
laneCwd="/Users/tester/lane-b"
onModeChange={vi.fn()}
onPromptChange={vi.fn()}
onCwdChange={vi.fn()}
onModelChange={vi.fn()}
@@ -0,0 +1,93 @@
/**
* @file TerminalView.test.tsx
* @description Tests for the xterm.js-backed terminal view: verifies it opens
* a WS connection to the right URL, writes incoming binary frames to the
* mocked terminal, and forwards typed input as outgoing binary frames.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { TerminalView } from "../TerminalView";
const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = [];
const openMock = vi.fn();
const disposeMock = vi.fn();
vi.mock("@xterm/xterm", () => ({
Terminal: vi.fn().mockImplementation(() => ({
open: openMock,
write: writeMock,
onData: (fn: (d: string) => void) => {
onDataHandlers.push(fn);
return { dispose: vi.fn() };
},
dispose: disposeMock,
loadAddon: vi.fn(),
})),
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
class MockWebSocket {
static instances: MockWebSocket[] = [];
url: string;
sent: unknown[] = [];
onopen: (() => void) | null = null;
onmessage: ((e: { data: unknown }) => void) | null = null;
onclose: (() => void) | null = null;
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
send(data: unknown) {
this.sent.push(data);
}
close() {
this.onclose?.();
}
}
// @ts-expect-error test override
global.WebSocket = MockWebSocket;
describe("TerminalView", () => {
beforeEach(() => {
MockWebSocket.instances = [];
onDataHandlers.length = 0;
writeMock.mockClear();
openMock.mockClear();
});
afterEach(cleanup);
it("opens a WS connection to the run's ws-pty path", () => {
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/ccam-lane-1");
});
it("writes incoming WS data to the terminal", () => {
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
ws.onmessage?.({ data: "hello" });
expect(writeMock).toHaveBeenCalledWith("hello");
});
it("decodes binary ArrayBuffer frames (server sends PTY output as binary)", () => {
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
const bytes = new TextEncoder().encode("hello-binary").buffer;
ws.onmessage?.({ data: bytes });
expect(writeMock).toHaveBeenCalledWith("hello-binary");
});
it("forwards terminal keystrokes as outgoing WS sends", () => {
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
onDataHandlers[0]!("ls -la\r");
expect(ws.sent).toEqual(["ls -la\r"]);
});
});
-5
View File
@@ -23,11 +23,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../lib/types`
*
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Public surface
* - `AgentCollaborationNetworkProps` exported API; see TSDoc on the symbol for behavior.
* - `AgentCollaborationNetwork` exported API; see TSDoc on the symbol for behavior.
@@ -25,11 +25,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
*
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
*
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
*
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
* - `../../lib/format`
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
*
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
* - `../../lib/format`
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/api`
* - `../../lib/format`
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
*
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
*
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
*
@@ -29,11 +29,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/api`
* - `../../lib/eventBus`
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../../lib/types`
*
@@ -1,174 +0,0 @@
/**
* @file useRunStream.test.tsx
* @description Covers `useRunStream`, the hook that owns the Run page's live
* envelope state: it subscribes to the event bus and folds `run_stream`
* envelopes into an array, forwards `run_status` / `run_input_ack` for the
* subscribed run id to the caller's callbacks, fires the id-agnostic
* `onAnyStatus` for every `run_status`, and disposes its subscription on
* unmount. `eventBus` is exercised for real (it is a plain in-memory pub/sub)
* with only its `subscribe` spied on, so the disposer assertion pins the real
* lifecycle rather than a mock's.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { eventBus } from "../../lib/eventBus";
import type { WSMessage } from "../../lib/types";
import { useRunStream, type Envelope } from "../useRunStream";
/** `run_stream` frame carrying one envelope for `id`. */
function streamMsg(id: string, envelope: unknown): WSMessage {
return { type: "run_stream", data: { id, envelope } } as WSMessage;
}
function statusMsg(id: string, status: string): WSMessage {
return { type: "run_status", data: { id, status, at: 1 } } as WSMessage;
}
const noopOpts = { onStatus: () => {}, onInputAck: () => {}, onAnyStatus: () => {} };
afterEach(() => {
vi.restoreAllMocks();
});
describe("useRunStream", () => {
it("merges envelopes for the subscribed run id in arrival order", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
eventBus.publish(streamMsg("run-1", { type: "system", subtype: "init" }));
eventBus.publish(streamMsg("run-1", { type: "result", subtype: "success" }));
});
expect(result.current.envelopes.map((e) => (e as { type: string }).type)).toEqual([
"system",
"result",
]);
});
it("ignores an envelope for a different run id", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
eventBus.publish(streamMsg("run-2", { type: "result" }));
});
expect(result.current.envelopes).toEqual([]);
});
it("updates a streaming assistant envelope in place instead of appending", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
// message_start seeds the kept envelope + a streaming placeholder.
eventBus.publish(
streamMsg("run-1", {
type: "stream_event",
event: { type: "message_start", message: { id: "m1" } },
})
);
});
expect(result.current.envelopes).toHaveLength(2);
act(() => {
eventBus.publish(
streamMsg("run-1", {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
message: { id: "m1" },
content_block: { type: "text", text: "" },
},
})
);
eventBus.publish(
streamMsg("run-1", {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
message: { id: "m1" },
delta: { type: "text_delta", text: "hi" },
},
})
);
});
// Still 2 envelopes: the deltas mutated the placeholder, they did not append.
expect(result.current.envelopes).toHaveLength(2);
const placeholder = result.current.envelopes[1] as {
message: { content: { text?: string }[]; _streaming?: boolean };
};
expect(placeholder.message.content[0]?.text).toBe("hi");
expect(placeholder.message._streaming).toBe(true);
});
it("invokes onStatus only for the subscribed run id, onAnyStatus for every run_status", () => {
const onStatus = vi.fn();
const onAnyStatus = vi.fn();
renderHook(() => useRunStream("run-1", { ...noopOpts, onStatus, onAnyStatus }));
act(() => {
eventBus.publish(statusMsg("run-1", "completed"));
eventBus.publish(statusMsg("run-2", "completed"));
});
expect(onStatus).toHaveBeenCalledTimes(1);
expect(onStatus.mock.calls[0]?.[0]).toMatchObject({ id: "run-1", status: "completed" });
expect(onAnyStatus).toHaveBeenCalledTimes(2);
});
it("invokes onInputAck only for the subscribed run id", () => {
const onInputAck = vi.fn();
renderHook(() => useRunStream("run-1", { ...noopOpts, onInputAck }));
act(() => {
eventBus.publish({ type: "run_input_ack", data: { id: "run-2" } } as WSMessage);
eventBus.publish({ type: "run_input_ack", data: { id: "run-1" } } as WSMessage);
});
expect(onInputAck).toHaveBeenCalledTimes(1);
});
it("ignores every frame while the run id is null", () => {
const onAnyStatus = vi.fn();
const { result } = renderHook(() => useRunStream(null, { ...noopOpts, onAnyStatus }));
act(() => {
eventBus.publish(streamMsg("run-1", { type: "result" }));
eventBus.publish(statusMsg("run-1", "completed"));
});
expect(result.current.envelopes).toEqual([]);
expect(onAnyStatus).toHaveBeenCalledTimes(1); // id-agnostic by design
});
it("disposes the event bus subscription on unmount", () => {
const dispose = vi.fn();
const subscribe = vi.spyOn(eventBus, "subscribe").mockReturnValue(dispose);
const { unmount } = renderHook(() => useRunStream("run-1", noopOpts));
expect(subscribe).toHaveBeenCalledTimes(1);
expect(dispose).not.toHaveBeenCalled();
unmount();
expect(dispose).toHaveBeenCalledTimes(1);
});
it("exposes setEnvelopes so the page can seed and clear the list", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
result.current.setEnvelopes([{ type: "user", message: { content: "hello" } } as Envelope]);
});
expect(result.current.envelopes).toHaveLength(1);
act(() => {
result.current.setEnvelopes([]);
});
expect(result.current.envelopes).toEqual([]);
});
});
-5
View File
@@ -20,11 +20,6 @@
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## Internal dependencies
* - `../i18n`
* - `../lib/eventBus`
-489
View File
@@ -1,489 +0,0 @@
/**
* @file useRunStream.ts
* @description Owns the Run page's live stream-json state. Subscribes to the
* WebSocket event bus and folds every `run_stream` envelope for one run id into
* an envelope array (`mergeEnvelope` and friends, moved here verbatim from
* `pages/Run.tsx`), exposes the typewriter-smoothed view of that array, and
* hands `run_status` / `run_input_ack` back to the caller the page still owns
* the `RunHandle` and the run-list refresh, so those arrive as callbacks.
*
* The stream-json envelope types live here too, since this hook is what
* produces them; the page imports them for rendering.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { eventBus } from "../lib/eventBus";
import type {
RunInputAckPayload,
RunStatusPayload,
RunStreamPayload,
WSMessage,
} from "../lib/types";
// ── Stream-json envelope shapes (the bits we render) ──────────────────
export type ContentBlock =
| { type: "text"; text: string }
| { type: "thinking"; thinking?: string }
| { type: "tool_use"; id: string; name: string; input: unknown }
| { type: "tool_result"; tool_use_id: string; content: unknown; is_error?: boolean };
export interface AssistantMessage {
type: "assistant";
message?: {
content?: ContentBlock[] | string;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
};
}
export interface UserMessage {
type: "user";
message?: { content?: ContentBlock[] | string };
}
export interface SystemInit {
type: "system";
subtype: "init";
session_id?: string;
model?: string;
cwd?: string;
tools?: string[];
permissionMode?: string;
}
export interface ResultEnvelope {
type: "result";
subtype?: string;
is_error?: boolean;
duration_ms?: number;
duration_api_ms?: number;
num_turns?: number;
result?: string;
session_id?: string;
total_cost_usd?: number;
usage?: { input_tokens?: number; output_tokens?: number };
}
export type Envelope =
| AssistantMessage
| UserMessage
| SystemInit
| ResultEnvelope
| { type: string; [k: string]: unknown };
// ── Streaming envelope merge ───────────────────────────────────────────
//
// `claude --output-format stream-json --include-partial-messages` emits two
// kinds of assistant output:
//
// 1. `stream_event` envelopes carrying Anthropic Messages API streaming
// events (`message_start`, `content_block_start`, `content_block_delta`,
// `content_block_stop`, `message_delta`, `message_stop`).
// 2. Eventually, a single complete `assistant` envelope summarising the turn.
//
// To make the chat actually stream character-by-character we accumulate the
// `stream_event` deltas into a synthetic assistant envelope. When the real
// `assistant` envelope arrives, we replace the synthetic one with it (their
// content is identical at that point, but the final envelope has authoritative
// usage / metadata).
interface StreamEventEnvelope {
type: "stream_event";
event?: {
type: string;
index?: number;
delta?: {
type: string;
text?: string;
thinking?: string;
partial_json?: string;
};
content_block?: {
type: string;
text?: string;
thinking?: string;
id?: string;
name?: string;
input?: unknown;
};
message?: { id?: string };
};
}
type StreamingAssistantBlock = ContentBlock & {
_partialJson?: string;
};
interface StreamingAssistantMessage {
type: "assistant";
_streamId?: string;
message: {
id?: string;
content: StreamingAssistantBlock[];
_streaming?: boolean;
};
}
function findLastStreamingAssistant(prev: Envelope[]): number {
for (let i = prev.length - 1; i >= 0; i--) {
const env = prev[i] as { type?: string; message?: { _streaming?: boolean } };
if (env?.type === "assistant" && env.message?._streaming) return i;
}
return -1;
}
function findAssistantByMessageId(prev: Envelope[], id: string | undefined): number {
if (!id) return findLastStreamingAssistant(prev);
for (let i = prev.length - 1; i >= 0; i--) {
const env = prev[i] as { type?: string; message?: { id?: string } };
if (env?.type === "assistant" && env.message?.id === id) return i;
}
return findLastStreamingAssistant(prev);
}
function mutateAssistantAt(
prev: Envelope[],
idx: number,
fn: (m: StreamingAssistantMessage["message"]) => StreamingAssistantMessage["message"]
): Envelope[] {
if (idx < 0) return prev;
const env = prev[idx] as StreamingAssistantMessage;
const next = [...prev];
next[idx] = {
...env,
message: fn(env.message || ({ content: [] } as StreamingAssistantMessage["message"])),
};
return next;
}
function mergeEnvelope(prev: Envelope[], envelope: Envelope): Envelope[] {
if (!envelope || typeof envelope !== "object") return prev;
const env = envelope as { type?: string };
if (env.type === "stream_event") {
const sse = envelope as StreamEventEnvelope;
const evt = sse.event;
if (!evt) return prev;
if (evt.type === "message_start") {
const placeholder: StreamingAssistantMessage = {
type: "assistant",
message: {
id: evt.message?.id,
content: [],
_streaming: true,
},
};
// Keep the message_start envelope itself in the array - its
// `event.message.usage` is the only place we get the initial input /
// cache token counts during live streaming. Without it, the meter is
// stuck at zero until the post-reload replay re-injects the same
// envelopes from the server.
return [...prev, envelope, placeholder as unknown as Envelope];
}
if (evt.type === "content_block_start") {
const idx = findAssistantByMessageId(prev, evt.message?.id);
if (idx < 0) return prev;
const blockIdx = evt.index ?? 0;
return mutateAssistantAt(prev, idx, (msg) => {
const blocks = [...(msg.content || [])];
blocks[blockIdx] = { ...(evt.content_block as ContentBlock) };
return { ...msg, content: blocks };
});
}
if (evt.type === "content_block_delta") {
const idx = findAssistantByMessageId(prev, evt.message?.id);
if (idx < 0) return prev;
const blockIdx = evt.index ?? 0;
return mutateAssistantAt(prev, idx, (msg) => {
const blocks = [...(msg.content || [])];
const block = (blocks[blockIdx] || {}) as StreamingAssistantBlock;
const next = { ...block } as StreamingAssistantBlock;
const delta = evt.delta;
if (delta?.type === "text_delta") {
(next as { text?: string }).text =
((next as { text?: string }).text || "") + (delta.text || "");
if (!next.type) (next as { type: string }).type = "text";
} else if (delta?.type === "thinking_delta") {
(next as { thinking?: string }).thinking =
((next as { thinking?: string }).thinking || "") + (delta.thinking || "");
if (!next.type) (next as { type: string }).type = "thinking";
} else if (delta?.type === "input_json_delta") {
// tool_use input streams as JSON-string fragments; accumulate, parse
// best-effort whenever the buffer is valid JSON.
next._partialJson = (next._partialJson || "") + (delta.partial_json || "");
try {
(next as { input?: unknown }).input = JSON.parse(next._partialJson);
} catch {
/* still incomplete JSON - leave previous parsed value */
}
}
blocks[blockIdx] = next;
return { ...msg, content: blocks };
});
}
if (evt.type === "message_stop") {
const idx = findAssistantByMessageId(prev, evt.message?.id);
if (idx < 0) return prev;
return mutateAssistantAt(prev, idx, (msg) => ({ ...msg, _streaming: false }));
}
if (evt.type === "message_delta") {
// message_delta carries the canonical per-message usage update (the
// running output_tokens for this turn). Keep the envelope so
// computeTokens can read it; otherwise the meter sits at the
// message_start placeholder value (output_tokens=4 etc) for the
// entire response.
return [...prev, envelope];
}
// content_block_start/stop and other stream_event subtypes are mutations
// on the placeholder we already track - no usage info, no need to keep
// the envelope itself.
return prev;
}
if (env.type === "assistant") {
// Claude emits the canonical `assistant` envelope BEFORE `message_stop`,
// so the message is still streaming at this point. Two regressions came
// out of replacing the placeholder wholesale here:
// 1. The `_streaming` flag was dropped, making the typewriter snap to
// full text the moment this envelope arrived.
// 2. The final envelope sometimes ships only the `text` content block
// (the `thinking` block we accumulated from `thinking_delta`s
// disappears), so the thinking section vanished as soon as the
// stream finished.
// Fix: when the placeholder was streaming, keep our delta-accumulated
// content (it's the authoritative record of every block) and only pull
// metadata from the incoming envelope. `message_stop` clears `_streaming`
// and the typewriter then reveals any unrevealed tail instantly.
const finalMsg = envelope as { message?: { id?: string; _streaming?: boolean } };
const idx = findAssistantByMessageId(prev, finalMsg.message?.id);
if (idx >= 0) {
const prevEnv = prev[idx] as StreamingAssistantMessage;
const next = [...prev];
if (prevEnv.message?._streaming) {
const incoming = envelope as { message?: Record<string, unknown> };
const incomingMsg = (incoming.message || {}) as Record<string, unknown>;
const accumulatedContent = prevEnv.message?.content || [];
const incomingContent = (incomingMsg as { content?: ContentBlock[] }).content;
// If the canonical envelope happens to carry MORE blocks (e.g. it
// includes a tool_use we hadn't seen as a stream_event yet), prefer
// it. Otherwise keep our accumulated blocks so we don't lose a
// thinking section the canonical envelope omitted.
const content =
Array.isArray(incomingContent) && incomingContent.length > accumulatedContent.length
? incomingContent
: accumulatedContent;
next[idx] = {
...envelope,
message: { ...incomingMsg, content, _streaming: true },
} as Envelope;
} else {
next[idx] = envelope;
}
return next;
}
return [...prev, envelope];
}
return [...prev, envelope];
}
/**
* Smooth out claude's bursty stream by dripping text/thinking deltas a few
* characters per frame. Without this, short responses (where claude emits
* the entire reply in one or two `text_delta` chunks) appear all-at-once.
* The hook returns a derived envelope list with each actively-streaming
* text/thinking block clamped to a displayed length that grows toward the
* server's target via requestAnimationFrame.
*/
function useTypewriterEnvelopes(envelopes: Envelope[]): Envelope[] {
const lengthsRef = useRef<Map<string, number>>(new Map());
const envRef = useRef<Envelope[]>(envelopes);
envRef.current = envelopes;
const [tick, setTick] = useState(0);
const rafRef = useRef<number | null>(null);
const tickFnRef = useRef<(() => void) | null>(null);
if (!tickFnRef.current) {
tickFnRef.current = function tickFn() {
const envs = envRef.current;
const lengths = lengthsRef.current;
let needsAnother = false;
let mutated = false;
for (let ei = 0; ei < envs.length; ei++) {
const env = envs[ei];
if (!env || (env as { type?: string }).type !== "assistant") continue;
const e = env as StreamingAssistantMessage;
const streaming = !!e.message?._streaming;
const blocks = e.message?.content || [];
for (let bi = 0; bi < blocks.length; bi++) {
const b = blocks[bi];
if (!b) continue;
let key: string;
let target: string;
if (b.type === "text") {
key = `${ei}:${bi}:t`;
target = (b as { text?: string }).text || "";
} else if (b.type === "thinking") {
key = `${ei}:${bi}:th`;
target = (b as { thinking?: string }).thinking || "";
} else {
continue;
}
const cur = lengths.get(key) ?? 0;
if (cur >= target.length) continue;
if (streaming) {
// Catch up to target in roughly 0.4s; bigger gaps drip faster.
const remaining = target.length - cur;
const step = Math.max(2, Math.ceil(remaining / 24));
lengths.set(key, Math.min(target.length, cur + step));
needsAnother = true;
mutated = true;
} else {
// Block is no longer streaming → reveal the rest instantly.
lengths.set(key, target.length);
mutated = true;
}
}
}
if (mutated) setTick((t) => (t + 1) & 0xffff);
rafRef.current = needsAnother
? requestAnimationFrame(tickFnRef.current as FrameRequestCallback)
: null;
};
}
// Single long-lived RAF loop. Reads envelopes via ref so new server data
// is picked up without tearing down and rescheduling the loop on every
// websocket message - a previous version restarted on each envelope
// change which dropped frames between bursts and hid the streaming.
useEffect(() => {
rafRef.current = requestAnimationFrame(tickFnRef.current as FrameRequestCallback);
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
}, []);
// Wake the loop when new envelopes arrive if it's parked (no pending work).
useEffect(() => {
if (rafRef.current == null && envelopes.length > 0) {
rafRef.current = requestAnimationFrame(tickFnRef.current as FrameRequestCallback);
}
}, [envelopes]);
// Reset lengths when envelopes shrink (e.g., the user starts a new run).
useEffect(() => {
if (envelopes.length === 0 && lengthsRef.current.size > 0) {
lengthsRef.current.clear();
}
}, [envelopes.length]);
return useMemo(() => {
const lengths = lengthsRef.current;
return envelopes.map((env, ei) => {
if (!env || (env as { type?: string }).type !== "assistant") return env;
const e = env as StreamingAssistantMessage;
const blocks = e.message?.content || [];
let changed = false;
const nextBlocks = blocks.map((b, bi) => {
if (b.type === "text") {
const full = (b as { text?: string }).text || "";
const len = lengths.get(`${ei}:${bi}:t`) ?? full.length;
if (len < full.length) {
changed = true;
return { ...b, text: full.slice(0, len) };
}
} else if (b.type === "thinking") {
const full = (b as { thinking?: string }).thinking || "";
const len = lengths.get(`${ei}:${bi}:th`) ?? full.length;
if (len < full.length) {
changed = true;
return { ...b, thinking: full.slice(0, len) };
}
}
return b;
});
if (!changed) return env;
return {
...e,
message: { ...e.message, content: nextBlocks },
} as unknown as Envelope;
});
// tick is intentionally a dep so this memo re-runs on each RAF step.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [envelopes, tick]);
}
/**
* Subscribe to the live stream of one run.
*
* `runId` is the id whose frames this hook cares about `null` while no run is
* attached. `onStatus` and `onInputAck` fire only for a payload matching
* `runId` (mirroring the page's old `handle && p.id === handle.id` guard);
* `onAnyStatus` fires for EVERY `run_status` frame regardless of id, because
* the page's run-list refresh has always been id-agnostic.
*/
export function useRunStream(
runId: string | null,
opts: {
onStatus: (p: RunStatusPayload) => void;
onInputAck: () => void;
onAnyStatus: () => void;
}
): {
envelopes: Envelope[];
setEnvelopes: React.Dispatch<React.SetStateAction<Envelope[]>>;
displayEnvelopes: Envelope[];
} {
const [envelopes, setEnvelopes] = useState<Envelope[]>([]);
const displayEnvelopes = useTypewriterEnvelopes(envelopes);
// Latest callbacks in a ref so the subscription's lifetime depends on the
// run id alone - re-subscribing whenever a caller passes a fresh closure
// would tear down and rebuild the bus handler on every page render.
const optsRef = useRef(opts);
optsRef.current = opts;
// WebSocket subscription - only act on messages for the current run.
useEffect(() => {
return eventBus.subscribe((msg: WSMessage) => {
if (msg.type === "run_stream") {
const p = msg.data as RunStreamPayload;
if (runId && p.id === runId) {
// React 18 auto-batches async setStates, which collapses bursts of
// stream_event deltas (and the final `assistant` envelope that
// follows them) into a single render - visually erasing the
// streaming effect. flushSync forces a commit per envelope so the
// user sees text_delta / thinking_delta chunks paint as they
// arrive instead of all at once.
flushSync(() => {
setEnvelopes((prev) => mergeEnvelope(prev, p.envelope as Envelope));
});
}
} else if (msg.type === "run_status") {
const p = msg.data as RunStatusPayload;
if (runId && p.id === runId) {
optsRef.current.onStatus(p);
}
optsRef.current.onAnyStatus();
} else if (msg.type === "run_input_ack") {
const p = msg.data as RunInputAckPayload;
if (runId && p.id === runId) {
optsRef.current.onInputAck();
}
}
});
}, [runId]);
return { envelopes, setEnvelopes, displayEnvelopes };
}

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