Compare commits

...

45 Commits

Author SHA1 Message Date
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
66 changed files with 6996 additions and 5205 deletions
+5 -4
View File
@@ -350,9 +350,10 @@ 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()` | | `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 | | `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/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/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/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 | | `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 |
| `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) | | `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. | | `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/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-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 |
@@ -623,7 +624,7 @@ graph LR
| `/analytics` | Analytics | `GET /api/analytics` | | `/analytics` | Analytics | `GET /api/analytics` |
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) | | `/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 | | `/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 | | `/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) | | `/*` | NotFound | None (static 404 page) |
+4
View File
@@ -35,6 +35,10 @@ RUN npm run build
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app 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 --from=server-deps /app/node_modules ./node_modules/
COPY package.json ./ COPY package.json ./
COPY server/ ./server/ COPY server/ ./server/
+13 -2
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. Internal build — all rights reserved.
*(Tiếng Việt: [README.vi.md](README.vi.md))*
## What it does ## What it does
- **Sessions, agents, events.** Everything Claude Code emits, recorded and - **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 - **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 that never calls `ccam stage` still shows progress — rendered dashed amber and
never as done, because an inference is not evidence. never as done, because an inference is not evidence.
- **Run Claude from the browser.** Spawn a session in a lane's directory, stream - **Run Claude from the browser.** A real terminal (tmux + a real PTY,
its output, send follow-ups, resume any past session. 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. - **Analytics, alerts, Kanban and a workflow view**, plus an MCP server and a CLI.
## Requirements ## Requirements
@@ -75,6 +79,7 @@ ccam status # is the dashboard up
ccam start # start it in the background and wait for healthy ccam start # start it in the background and wait for healthy
ccam sessions # recent sessions ccam sessions # recent sessions
ccam lanes # lanes with stage and progress 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 stage <name> # declare the current lane's stage
ccam tail # live event feed ccam tail # live event feed
``` ```
@@ -101,6 +106,12 @@ 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, move backwards between work sessions. Detection never writes the declared stage,
and an inferred node never renders as done. 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 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 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 declarations plus shell hooks the dashboard calls. Each lane gets a slot, and its
+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
+33
View File
@@ -2300,6 +2300,31 @@ async function cmdLanesPipeline(args) {
} }
} }
/**
* 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) { async function cmdFeatureShow(args) {
const slug = args.find((arg) => !arg.startsWith("--")); const slug = args.find((arg) => !arg.startsWith("--"));
if (!slug) { if (!slug) {
@@ -2484,6 +2509,11 @@ const COMMAND_GROUPS = [
"[<template-id>] [<id>]", "[<template-id>] [<id>]",
"Show, or switch, which pipeline template a lane renders against", "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", "lanes reset|remove|purge",
"<id> [--force] [--keep-db] --yes", "<id> [--force] [--keep-db] --yes",
@@ -3362,6 +3392,9 @@ async function runCommand(argv) {
if (rest[0] === "pipeline") { if (rest[0] === "pipeline") {
return cmdLanesPipeline(rest.slice(1)); return cmdLanesPipeline(rest.slice(1));
} }
if (rest[0] === "shell") {
return cmdLanesShell(rest.slice(1));
}
if (rest[0] === "gc") { if (rest[0] === "gc") {
return cmdLanesGc(rest.slice(1)); return cmdLanesGc(rest.slice(1));
} }
+17
View File
@@ -10,6 +10,8 @@
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^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": "^7.9.0",
"d3-sankey": "^0.12.3", "d3-sankey": "^0.12.3",
"i18next": "^26.0.8", "i18next": "^26.0.8",
@@ -2100,6 +2102,21 @@
"url": "https://opencollective.com/vitest" "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": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+2
View File
@@ -13,6 +13,8 @@
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^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": "^7.9.0",
"d3-sankey": "^0.12.3", "d3-sankey": "^0.12.3",
"i18next": "^26.0.8", "i18next": "^26.0.8",
@@ -167,26 +167,11 @@ describe("reduceTabby counts and pulses", () => {
expect(ok.state.worriedUntil).toBe(0); expect(ok.state.worriedUntil).toBe(0);
}); });
it("run_status completed exit 0 is happy, nonzero/error/killed is worried", () => { it("run_status updates activity timestamp only (no exit code available)", () => {
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");
const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0); const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0);
expect(running.pulse).toBe(null); 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", () => { it("any handled message refreshes lastActivityAt", () => {
+1 -19
View File
@@ -340,25 +340,7 @@ export function reduceTabby(
case "run_status": { case "run_status": {
const r = msg.data as RunStatusPayload; const r = msg.data as RunStatusPayload;
if (!r) return { state, pulse: null }; if (!r) return { state, pulse: null };
// A run that finished cleanly (exit 0, or no exit code reported) → happy. // running / gone → activity only (no exit code to distinguish success/failure).
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.
return { state: { ...state, lastActivityAt: now }, pulse: null }; return { state: { ...state, lastActivityAt: now }, pulse: null };
} }
@@ -1,9 +1,9 @@
/** /**
* @file The compact lane tile used in the Workspace carousel. It carries only * @file The compact lane tile used in the Workspace's vertical lane list. It
* what you need to pick a lane which lane, is it alive, what stage, how far * carries only what you need to pick a lane which lane, is it alive, what
* because the full card, its controls and its working-copy facts live in the * stage, how far because the full card, its controls and its working-copy
* detail panel below. Keeping the tile small is what lets a dozen lanes stay * facts live in the detail panel beside it. Keeping the tile small and full
* scannable in one horizontal row. * width is what lets many lanes stay scannable in one scrolling column.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/ */
@@ -42,7 +42,7 @@ export default function LaneStripCard({
aria-pressed={selected} aria-pressed={selected}
onClick={onSelect} onClick={onSelect}
title={lane.cwd} 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 selected
? "border-accent bg-accent/10" ? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3" : "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
@@ -0,0 +1,403 @@
/**
* @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`, and
* `activeRuns` 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.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, 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;
wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void;
}
export function LaneConsolePane({
lanes,
laneId,
showLaneSelector,
onLaneIdChange,
onLaneCreated,
binaryStatus,
cwdSuggestions,
activeRuns,
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]);
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}
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
+33 -35
View File
@@ -33,8 +33,27 @@ import {
RotateCcw, RotateCcw,
Eye, Eye,
} from "lucide-react"; } from "lucide-react";
import type { DashboardRunHistoryItem, RunListResponse, RunMode, RunStatus } from "../../lib/api"; import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
import { ModeBadge, StatusPill } from "./RunConsole";
// 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 = type RunStatusFilter =
| "all" | "all"
@@ -44,15 +63,13 @@ type RunStatusFilter =
| "error" | "error"
| "killed" | "killed"
| "abandoned"; | "abandoned";
type RunModeFilter = "all" | "conversation" | "headless";
export interface UnifiedRunRow { export interface UnifiedRunRow {
id: string; id: string;
sessionId: string | null; sessionId: string | null;
mode: RunMode;
cwd: string; cwd: string;
model: string | null; model: string | null;
status: RunStatus; status: RunStatus | "completed" | "error" | "killed" | "abandoned";
promptPreview: string; promptPreview: string;
startedAt: number; startedAt: number;
endedAt: number | null; endedAt: number | null;
@@ -105,14 +122,13 @@ export function ActiveRunsSwitcher({
out.push({ out.push({
id: r.id, id: r.id,
sessionId: r.sessionId, sessionId: r.sessionId,
mode: r.mode, cwd: r.cwd || "",
cwd: r.cwd,
model: r.model, model: r.model,
status: r.status, status: r.status,
promptPreview: r.prompt || "", promptPreview: r.promptPreview || "",
startedAt: r.startedAt, startedAt: r.startedAt ? new Date(r.startedAt).getTime() : 0,
endedAt: r.endedAt, endedAt: null,
isLive: r.status === "running" || r.status === "spawning", isLive: r.status === "running",
}); });
} }
} }
@@ -124,7 +140,6 @@ export function ActiveRunsSwitcher({
out.push({ out.push({
id: h.id, id: h.id,
sessionId: h.session_id, sessionId: h.session_id,
mode: h.mode,
cwd: h.cwd, cwd: h.cwd,
model: h.model, model: h.model,
status: h.status, status: h.status,
@@ -138,7 +153,7 @@ export function ActiveRunsSwitcher({
return out; return out;
}, [activeRuns, runHistory]); }, [activeRuns, runHistory]);
const liveCount = activeRuns?.activeCount ?? 0; const liveCount = rows.filter((r) => r.isLive).length;
const totalCount = rows.length; const totalCount = rows.length;
return ( return (
@@ -211,7 +226,6 @@ export function RunsModal({
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all");
const [modeFilter, setModeFilter] = useState<RunModeFilter>("all");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
// Snappy refresh while the modal is the foreground UI: pull immediately // Snappy refresh while the modal is the foreground UI: pull immediately
@@ -227,25 +241,22 @@ export function RunsModal({
const counts = useMemo(() => { const counts = useMemo(() => {
const byStatus: Record<string, number> = { all: rows.length }; const byStatus: Record<string, number> = { all: rows.length };
const byMode: Record<string, number> = { all: rows.length };
for (const r of rows) { for (const r of rows) {
byStatus[r.status] = (byStatus[r.status] || 0) + 1; byStatus[r.status] = (byStatus[r.status] || 0) + 1;
byMode[r.mode] = (byMode[r.mode] || 0) + 1;
} }
return { byStatus, byMode }; return { byStatus };
}, [rows]); }, [rows]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
return rows.filter((r) => { return rows.filter((r) => {
if (statusFilter !== "all" && r.status !== statusFilter) return false; if (statusFilter !== "all" && r.status !== statusFilter) return false;
if (modeFilter !== "all" && r.mode !== modeFilter) return false;
if (!q) return true; if (!q) return true;
const hay = const hay =
r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || ""); r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || "");
return hay.toLowerCase().includes(q); return hay.toLowerCase().includes(q);
}); });
}, [rows, statusFilter, modeFilter, search]); }, [rows, statusFilter, search]);
const historyById = useMemo(() => { const historyById = useMemo(() => {
const m = new Map<string, DashboardRunHistoryItem>(); const m = new Map<string, DashboardRunHistoryItem>();
@@ -261,7 +272,6 @@ export function RunsModal({
"killed", "killed",
"abandoned", "abandoned",
]; ];
const MODES: RunModeFilter[] = ["all", "conversation", "headless"];
return ( return (
<div <div
@@ -343,16 +353,6 @@ export function RunsModal({
}))} }))}
onChange={(v) => setStatusFilter(v as RunStatusFilter)} 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>
</div> </div>
@@ -467,10 +467,9 @@ function UnifiedRunRowView({
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
const canResume = row.mode === "conversation" && !!row.sessionId && !row.isLive; // Without mode distinction, offer resume for any finished run with a session
// Headless runs are single-shot, so resume doesn't apply - but the captured const canResume = !!row.sessionId && !row.isLive;
// transcript is still worth viewing. Link to the Session detail page. const canView = !!row.sessionId && !row.isLive;
const canView = row.mode === "headless" && !!row.sessionId && !row.isLive;
return ( return (
<div <div
className={`px-5 py-3 transition-colors ${ className={`px-5 py-3 transition-colors ${
@@ -479,7 +478,6 @@ function UnifiedRunRowView({
> >
<div className="flex items-center gap-2 mb-1.5 flex-wrap"> <div className="flex items-center gap-2 mb-1.5 flex-wrap">
<StatusPill status={row.status} /> <StatusPill status={row.status} />
<ModeBadge mode={row.mode} />
{row.isLive && ( {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="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" /> <span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
+89 -77
View File
@@ -5,14 +5,13 @@
* the Run page and the Workspace page can both mount the same panel. * the Run page and the Workspace page can both mount the same panel.
* *
* What lives here: * What lives here:
* - `RunSetup` mode (conversation / headless), fresh-vs-resume source, the * - `RunSetup` fresh-vs-resume source, the prompt editor, and the cwd /
* prompt editor, and the cwd / model / permission-mode / effort fields, * model / permission-mode / effort fields, plus the concurrency hint and
* plus the concurrency hint and the Start button. Its disabled state is * the Start button. Its disabled state is driven by the `binaryFound` prop,
* driven by the `binaryFound` prop, so a missing `claude` binary is a * so a missing `claude` binary is a surfaced state here rather than a probe
* surfaced state here rather than a probe of its own. * of its own.
* above the panel, with its own localStorage-persisted minimized state.
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`, * - 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 * 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 * lifecycle the page owns `api.run.start` and hands the result back through
@@ -45,18 +44,53 @@ import type {
RunListResponse, RunListResponse,
EffortLevel, EffortLevel,
PermissionMode, PermissionMode,
RunMode, RunStartArgs,
} from "../../lib/api"; } from "../../lib/api";
import type { Session } from "../../lib/types"; import type { Session } from "../../lib/types";
import { Select } from "../Select"; 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) ──────────────────────── // ── Limitations banner (above the config card) ────────────────────────
interface RunSetupProps { interface RunSetupProps {
mode: RunMode; laneId: number;
onModeChange: (m: RunMode) => void;
prompt: string; prompt: string;
onPromptChange: (s: string) => void; onPromptChange: (s: string) => void;
cwd: string; cwd: string;
@@ -70,7 +104,7 @@ interface RunSetupProps {
onEffortChange: (e: EffortLevel) => void; onEffortChange: (e: EffortLevel) => void;
binaryFound: boolean; binaryFound: boolean;
busy: boolean; busy: boolean;
onStart: () => void; onStart: (args: RunStartArgs) => void;
activeRuns: RunListResponse | null; activeRuns: RunListResponse | null;
resumeSession: Session | null; resumeSession: Session | null;
onResumeSessionChange: (s: Session | null) => void; onResumeSessionChange: (s: Session | null) => void;
@@ -78,81 +112,52 @@ interface RunSetupProps {
* sessions. Undefined when no lane is selected (the picker then lists * sessions. Undefined when no lane is selected (the picker then lists
* everything, same as before lanes existed). */ * everything, same as before lanes existed). */
laneCwd?: string; laneCwd?: string;
slashCommands: SlashCommand[]; slashCommands?: SlashCommand[];
runHistory: DashboardRunHistoryItem[]; runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void; onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
} }
export function RunSetup(props: RunSetupProps) { export function RunSetup(props: RunSetupProps) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const atCap = const atCap = false; // TODO: re-add when concurrency info is available
props.activeRuns != null && props.activeRuns.activeCount >= props.activeRuns.maxConcurrent;
const isResume = !!props.resumeSession; const isResume = !!props.resumeSession;
const [resumePicked, setResumePicked] = useState(isResume); const [resumePicked, setResumePicked] = useState(isResume);
// Keep "resume picked" in sync with the parent. Two cases: // Keep "resume picked" in sync with the parent. Parent set a resume session
// 1. Parent set a resume session (e.g. user clicked Resume in the runs // (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
// modal) - flip the radio so the picker is shown and the selection // is shown and the selection is visible.
// is visible.
// 2. Parent cleared the session and mode flipped to headless - clear
// the radio so the form is honest.
useEffect(() => { useEffect(() => {
if (isResume && !resumePicked) setResumePicked(true); if (isResume && !resumePicked) setResumePicked(true);
else if (!isResume && resumePicked && props.mode === "headless") setResumePicked(false); }, [isResume, resumePicked]);
}, [isResume, resumePicked, props.mode]);
return ( return (
<div className="rounded-xl border border-border bg-surface-1"> <div className="rounded-xl border border-border bg-surface-1">
{/* Mode and source on one line. Both are two-way choices made once at {/* Fresh vs resume source picker */}
spawn time, so a segmented row carries them; the longer explanations
live in each button's title rather than in a paragraph. */}
<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 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"> <div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg <Seg
active={props.mode === "conversation"} active={!resumePicked}
label={t("mode.conversation")} label={t("resume.freshOption")}
title={t("mode.conversationHint")} title={t("resume.freshHint")}
onClick={() => props.onModeChange("conversation")}
/>
<Seg
active={props.mode === "headless"}
label={t("mode.headless")}
title={`${t("mode.headlessHint")}${t("hint.headlessExplain")}`}
onClick={() => { onClick={() => {
props.onModeChange("headless");
setResumePicked(false); setResumePicked(false);
props.onResumeSessionChange(null);
}} }}
/> />
<Seg
active={resumePicked}
label={t("resume.resumeOption")}
title={t("resume.resumeHint")}
onClick={() => setResumePicked(true)}
/>
</div> </div>
{resumePicked && (
{props.mode === "conversation" && ( <div className="min-w-0 flex-1">
<> <SessionPicker
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5"> selected={props.resumeSession}
<Seg onSelect={props.onResumeSessionChange}
active={!resumePicked} cwd={props.laneCwd}
label={t("resume.freshOption")} />
title={t("resume.freshHint")} </div>
onClick={() => {
setResumePicked(false);
props.onResumeSessionChange(null);
}}
/>
<Seg
active={resumePicked}
label={t("resume.resumeOption")}
title={t("resume.resumeHint")}
onClick={() => setResumePicked(true)}
/>
</div>
{resumePicked && (
<div className="min-w-0 flex-1">
<SessionPicker
selected={props.resumeSession}
onSelect={props.onResumeSessionChange}
cwd={props.laneCwd}
/>
</div>
)}
</>
)} )}
</div> </div>
@@ -164,10 +169,10 @@ export function RunSetup(props: RunSetupProps) {
<PromptEditor <PromptEditor
value={props.prompt} value={props.prompt}
onChange={props.onPromptChange} onChange={props.onPromptChange}
onSubmit={props.onStart} onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholder")} placeholder={t("fields.promptPlaceholderTerminal")}
rows={5} rows={5}
slashCommands={props.slashCommands} slashCommands={props.slashCommands ?? []}
fileCwd={props.resumeSession?.cwd || props.cwd} fileCwd={props.resumeSession?.cwd || props.cwd}
/> />
<div className="mt-1 text-[10px] text-fg-muted"> <div className="mt-1 text-[10px] text-fg-muted">
@@ -234,17 +239,12 @@ export function RunSetup(props: RunSetupProps) {
{atCap ? ( {atCap ? (
<span className="inline-flex items-center gap-1.5 text-status-warning"> <span className="inline-flex items-center gap-1.5 text-status-warning">
<AlertCircle className="w-3.5 h-3.5" /> <AlertCircle className="w-3.5 h-3.5" />
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })} {t("concurrency.atCap", { max: 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 })}
</span> </span>
) : null} ) : null}
</div> </div>
<button <button
onClick={props.onStart} onClick={() => handleStart(props)}
disabled={ disabled={
!props.binaryFound || !props.binaryFound ||
!props.prompt.trim() || !props.prompt.trim() ||
@@ -270,6 +270,18 @@ export function RunSetup(props: RunSetupProps) {
); );
} }
function handleStart(props: RunSetupProps) {
props.onStart({
laneId: props.laneId,
cwd: props.cwd || undefined,
model: props.model || undefined,
permissionMode: props.permissionMode || undefined,
effort: props.effort || undefined,
resumeSessionId: props.resumeSession?.id || undefined,
initialPrompt: props.prompt || undefined,
});
}
/** One segment of a two-way inline choice. The explanation rides on `title` /** 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. */ * instead of a hint line, which is what keeps the row to one line. */
function Seg({ 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,147 @@
/**
* @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("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();
});
});
@@ -29,12 +29,11 @@ const activeRuns = {
{ {
id: LIVE_ID, id: LIVE_ID,
sessionId: "sess-live", sessionId: "sess-live",
mode: "conversation",
cwd: "/tmp/live", cwd: "/tmp/live",
model: "claude-opus-5", model: "claude-opus-5",
status: "running", status: "running",
prompt: "the live prompt", startedAt: "2000-01-01T00:50:00Z",
startedAt: 3000, promptPreview: "the live prompt",
endedAt: null, endedAt: null,
}, },
], ],
@@ -44,7 +43,6 @@ function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistor
return { return {
id: PAST_ID, id: PAST_ID,
session_id: "sess-past", session_id: "sess-past",
mode: "conversation",
cwd: "/tmp/past", cwd: "/tmp/past",
model: "sonnet", model: "sonnet",
status: "completed", status: "completed",
@@ -64,7 +62,6 @@ const PAST = historyItem({});
const HEADLESS = historyItem({ const HEADLESS = historyItem({
id: HEADLESS_ID, id: HEADLESS_ID,
session_id: "sess-headless", session_id: "sess-headless",
mode: "headless",
cwd: "/tmp/headless", cwd: "/tmp/headless",
prompt_preview: "the headless prompt", prompt_preview: "the headless prompt",
started_at: new Date(1000).toISOString(), started_at: new Date(1000).toISOString(),
@@ -95,10 +92,9 @@ function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
return { return {
id, id,
sessionId: `sess-${id}`, sessionId: `sess-${id}`,
mode: "conversation",
cwd: `/tmp/${id}`, cwd: `/tmp/${id}`,
model: "sonnet", model: "sonnet",
status: "completed", status: "abandoned",
promptPreview: `prompt of ${id}`, promptPreview: `prompt of ${id}`,
startedAt: 1000, startedAt: 1000,
endedAt: 2000, endedAt: 2000,
@@ -216,39 +212,31 @@ describe("RunsModal", () => {
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID); 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)]); const { spies } = renderModal([row(PAST_ID)]);
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
expect(spies.onResume).toHaveBeenCalledWith(PAST); expect(spies.onResume).toHaveBeenCalledWith(PAST);
expect(spies.onView).not.toHaveBeenCalled();
}); });
it("fires view — not resume — for a finished headless row", () => { it("fires view for a finished row", () => {
const { spies } = renderModal([row(HEADLESS_ID, { mode: "headless" })]); const { spies } = renderModal([row(HEADLESS_ID)]);
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel"))); fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
expect(spies.onView).toHaveBeenCalledWith(HEADLESS); 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 = [ const rows = [
row("a", { status: "running", isLive: true, promptPreview: "alpha" }), row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
row("b", { status: "error", promptPreview: "bravo" }), row("b", { status: "killed", promptPreview: "bravo" }),
row("c", { status: "completed", mode: "headless", promptPreview: "charlie" }), row("c", { status: "abandoned", promptPreview: "charlie" }),
]; ];
renderModal(rows); 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.getByText("bravo")).toBeTruthy();
expect(screen.queryByText("alpha")).toBeNull(); expect(screen.queryByText("alpha")).toBeNull();
fireEvent.click(allChip(0)); 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( fireEvent.change(
screen.getByPlaceholderText( screen.getByPlaceholderText(
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…") 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>> = {}) { function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
const spies = { const spies = {
onModeChange: vi.fn(),
onPromptChange: vi.fn(), onPromptChange: vi.fn(),
onCwdChange: vi.fn(), onCwdChange: vi.fn(),
onModelChange: vi.fn(), onModelChange: vi.fn(),
@@ -52,7 +51,7 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
const utils = render( const utils = render(
<MemoryRouter> <MemoryRouter>
<RunSetup <RunSetup
mode="conversation" laneId={1}
prompt="do the thing" prompt="do the thing"
cwd="/Users/tester" cwd="/Users/tester"
cwdSuggestions={SUGGESTIONS} cwdSuggestions={SUGGESTIONS}
@@ -95,15 +94,6 @@ beforeEach(() => {
}); });
describe("RunSetup — selections report through callbacks", () => { 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", () => { it("reports the prompt from the editor", () => {
const { spies } = renderSetup({ prompt: "" }); const { spies } = renderSetup({ prompt: "" });
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder")); 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); 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: " " }); const { unmount } = renderSetup({ prompt: " " });
expect(runButton().disabled).toBe(true); expect(runButton().disabled).toBe(true);
unmount(); unmount();
@@ -179,12 +169,6 @@ describe("RunSetup — missing binary and other blocked states", () => {
const noCwd = renderSetup({ cwd: "" }); const noCwd = renderSetup({ cwd: "" });
expect(runButton().disabled).toBe(true); expect(runButton().disabled).toBe(true);
noCwd.unmount(); 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", () => { it("shows the Starting… label while busy", () => {
@@ -237,7 +221,7 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
rerender( rerender(
<MemoryRouter> <MemoryRouter>
<RunSetup <RunSetup
mode="conversation" laneId={1}
prompt="do the thing" prompt="do the thing"
cwd="/Users/tester" cwd="/Users/tester"
cwdSuggestions={SUGGESTIONS} cwdSuggestions={SUGGESTIONS}
@@ -251,7 +235,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
slashCommands={[]} slashCommands={[]}
runHistory={[]} runHistory={[]}
laneCwd="/Users/tester/lane-b" laneCwd="/Users/tester/lane-b"
onModeChange={vi.fn()}
onPromptChange={vi.fn()} onPromptChange={vi.fn()}
onCwdChange={vi.fn()} onCwdChange={vi.fn()}
onModelChange={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"]);
});
});
@@ -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([]);
});
});
-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 };
}
+6
View File
@@ -90,6 +90,8 @@
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked", "git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
"kind.adopted": "adopted", "kind.adopted": "adopted",
"kind.managed": "managed", "kind.managed": "managed",
"laneDetail.hide": "Hide details",
"laneDetail.show": "Lane details",
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}", "laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
"locks.held_one": "{{count}} lock held", "locks.held_one": "{{count}} lock held",
"locks.held_other": "{{count}} locks held", "locks.held_other": "{{count}} locks held",
@@ -120,6 +122,10 @@
"features.archived": "archived", "features.archived": "archived",
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.", "features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
"proof.ticketReport": "Task report", "proof.ticketReport": "Task report",
"splitView.emptyPane": "No lane selected for this pane.",
"splitView.paneLaneLabel": "Pane lane selector",
"splitView.paneCount": "{{count}} pane",
"splitView.pickLane": "Pick a lane",
"statusDead": "DEAD", "statusDead": "DEAD",
"title": "Lanes", "title": "Lanes",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message" "tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
+2 -1
View File
@@ -74,7 +74,8 @@
"permissionMode": "Permission mode", "permissionMode": "Permission mode",
"permissionPlan": "plan (read-only planning)", "permissionPlan": "plan (read-only planning)",
"prompt": "Prompt", "prompt": "Prompt",
"promptPlaceholder": "Ask Claude anything…" "promptPlaceholder": "Ask Claude anything…",
"promptPlaceholderTerminal": "Ask Claude anything…"
}, },
"footer": { "footer": {
"cost": "Cost", "cost": "Cost",
+6
View File
@@ -90,6 +90,8 @@
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi", "git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
"kind.adopted": "đã nhận", "kind.adopted": "đã nhận",
"kind.managed": "được quản lý", "kind.managed": "được quản lý",
"laneDetail.hide": "Ẩn chi tiết",
"laneDetail.show": "Chi tiết lane",
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}", "laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
"locks.held_one": "Đang giữ {{count}} khóa", "locks.held_one": "Đang giữ {{count}} khóa",
"locks.held_other": "Đang giữ {{count}} khóa", "locks.held_other": "Đang giữ {{count}} khóa",
@@ -120,6 +122,10 @@
"features.archived": "đã lưu trữ", "features.archived": "đã lưu trữ",
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.", "features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
"proof.ticketReport": "Báo cáo nhiệm vụ", "proof.ticketReport": "Báo cáo nhiệm vụ",
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
"splitView.paneCount": "{{count}} ô",
"splitView.pickLane": "Chọn lane",
"statusDead": "ĐÃ CHẾT", "statusDead": "ĐÃ CHẾT",
"title": "Làn đường", "title": "Làn đường",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn" "tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn"
+2 -1
View File
@@ -73,7 +73,8 @@
"permissionMode": "Permission mode", "permissionMode": "Permission mode",
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)", "permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
"prompt": "Prompt", "prompt": "Prompt",
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…" "promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
"promptPlaceholderTerminal": "Hỏi Claude bất cứ điều gì…"
}, },
"footer": { "footer": {
"cost": "Chi phí", "cost": "Chi phí",
@@ -0,0 +1,44 @@
/**
* @file splitViewStorage.test.ts
* @description Tests for the splitViewStorage module.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import {
readSplitViewState,
writeSplitViewState,
defaultSplitViewState,
} from "../splitViewStorage";
describe("splitViewStorage", () => {
beforeEach(() => {
localStorage.clear();
});
it("returns the default state when nothing is stored", () => {
expect(readSplitViewState()).toEqual(defaultSplitViewState());
});
it("defaults to a single unselected pane", () => {
expect(defaultSplitViewState()).toEqual({ layout: 1, paneLaneIds: [null] });
});
it("round-trips a written state", () => {
writeSplitViewState({ layout: 4, paneLaneIds: [1, 2, null, null] });
expect(readSplitViewState()).toEqual({ layout: 4, paneLaneIds: [1, 2, null, null] });
});
it("falls back to the default when stored JSON is malformed", () => {
localStorage.setItem("ccam.workspace.splitView", "{not json");
expect(readSplitViewState()).toEqual(defaultSplitViewState());
});
it("falls back to the default when the stored layout is not 1, 2, or 4", () => {
localStorage.setItem(
"ccam.workspace.splitView",
JSON.stringify({ layout: 3, paneLaneIds: [] })
);
expect(readSplitViewState()).toEqual(defaultSplitViewState());
});
});
+34 -145
View File
@@ -1474,110 +1474,32 @@ export const api = {
/** Spawn/manage headless or conversational `claude` CLI child processes /** Spawn/manage headless or conversational `claude` CLI child processes
* launched from the dashboard's Run page, and stream their output. */ * launched from the dashboard's Run page, and stream their output. */
run: { run: {
/** /** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
* GET /api/run - currently tracked runs (in-memory handles) plus
* concurrency limits.
* @returns {@link RunListResponse} live handles + `maxConcurrent`/`activeCount`.
*/
list: () => request<RunListResponse>("/run"), list: () => request<RunListResponse>("/run"),
/** /** GET /api/run/history - persisted run history from `dashboard_runs`. */
* GET /api/run/history - persisted run history from the `dashboard_runs`
* table, including runs whose in-memory handle has since been reaped.
* Optionally filter by lane.
*
* `limit` defaults to 50 when the caller omits it and is always sent as a
* query param.
*
* @param limit Max history rows to return (default 50).
* @param options Optional filters like laneId.
* @returns `{ items }` {@link DashboardRunHistoryItem} rows, newest-first.
*/
history: (limit = 50, options?: { laneId?: number }) => { history: (limit = 50, options?: { laneId?: number }) => {
const qs = new URLSearchParams({ limit: String(limit) }); const qs = new URLSearchParams({ limit: String(limit) });
if (options?.laneId !== undefined) qs.set("laneId", String(options.laneId)); if (options?.laneId !== undefined) qs.set("laneId", String(options.laneId));
return request<{ items: DashboardRunHistoryItem[] }>(`/run/history?${qs.toString()}`); return request<{ items: DashboardRunHistoryItem[] }>(`/run/history?${qs.toString()}`);
}, },
/** /** GET /api/run/binary - whether `claude` was found on PATH. */
* GET /api/run/binary - whether a `claude` executable was found on PATH.
*
* Lets the Run page disable/enable the "start" affordance and show where the
* CLI resolved from (or that it's missing).
*
* @returns `{ found, path }` whether a binary was located and its path.
*/
binary: () => request<{ found: boolean; path: string | null }>("/run/binary"), binary: () => request<{ found: boolean; path: string | null }>("/run/binary"),
/** /** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */
* GET /api/run/cwds - suggested working directories for the cwd picker. tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
* @returns `{ items }` {@link CwdSuggestion} entries (dashboard/home/recent). /** GET /api/run/cwds - suggested working directories for the cwd picker. */
*/
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"), cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** /** GET /api/run/files - path-completion suggestions under `cwd`. */
* GET /api/run/files - path-completion suggestions under `cwd`, filtered
* by an optional query fragment `q`.
*
* Backs the file/@-mention autocomplete when composing a run prompt: `cwd`
* is always sent; `q` is appended only when non-empty to narrow matches.
*
* @param cwd The directory to complete paths within.
* @param q Optional partial fragment to filter suggestions by.
* @returns `{ items }` matching path strings under `cwd`.
*/
files: (cwd: string, q?: string) => { files: (cwd: string, q?: string) => {
const qs = new URLSearchParams({ cwd }); const qs = new URLSearchParams({ cwd });
if (q) qs.set("q", q); if (q) qs.set("q", q);
return request<{ items: string[] }>(`/run/files?${qs.toString()}`); return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
}, },
/** /** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
* POST /api/run - spawn a new `claude` child process.
*
* Sends {@link RunStartArgs} (prompt, mode, and optional cwd/model/
* permission-mode/resume/effort). The server spawns the CLI and returns the
* initial {@link RunHandle}; subsequent output is streamed over the
* `run_stream` WebSocket message rather than this response.
*
* @param args The spawn parameters.
* @returns {@link RunHandle} the freshly created run's handle.
*/
start: (args: RunStartArgs) => start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }), request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
/** /** GET /api/run/:id - one run's current handle. */
* GET /api/run/:id - one run's current handle; pass `envelopes: true` to get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
* also include its buffered stream-json envelopes (for a page refresh /** DELETE /api/run/:id - kill the tmux session. */
* mid-run, since the WS `run_stream` history isn't otherwise replayed).
*
* The `envelopes` flag is translated to `?envelopes=1`. Use it when
* re-hydrating the Run page after a reload: the WebSocket only pushes *new*
* envelopes, so the buffered ones must be pulled once to backfill the view.
*
* @param id The run id.
* @param opts Optional `{ envelopes }` include buffered stream-json envelopes.
* @returns {@link RunHandle} the run's handle (with `envelopes` when requested).
*/
get: (id: string, opts?: { envelopes?: boolean }) =>
request<RunHandle>(`/run/${encodeURIComponent(id)}${opts?.envelopes ? "?envelopes=1" : ""}`),
/**
* POST /api/run/:id/message - write `text` to the run's stdin (conversation
* mode only); acked via the `run_input_ack` WS message.
*
* Only meaningful for a run started in "conversation" mode (stdin left
* open). The HTTP response returns just the `messageId`; the actual
* delivery/echo is confirmed asynchronously over the WebSocket.
*
* @param id The run id to send input to.
* @param text The user's follow-up message written to the CLI's stdin.
* @returns `{ messageId }` id correlating this input with its `run_input_ack`.
*/
send: (id: string, text: string) =>
request<{ messageId: string }>(`/run/${encodeURIComponent(id)}/message`, {
method: "POST",
body: JSON.stringify({ text }),
}),
/**
* DELETE /api/run/:id - forcibly terminate a running process.
*
* @param id The run id to kill.
* @returns `{ ok: true }` acknowledgement that termination was requested.
*/
kill: (id: string) => kill: (id: string) =>
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }), request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
}, },
@@ -2506,78 +2428,51 @@ export interface CcHookScripts {
// mirror the CLI's own vocabulary so the dashboard can drive the CLI faithfully. // mirror the CLI's own vocabulary so the dashboard can drive the CLI faithfully.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** "headless" runs to completion unattended and streams only output;
* "conversation" keeps stdin open so the user can send follow-up messages. */
export type RunMode = "headless" | "conversation";
/** Lifecycle of a spawned `claude` process, mirrored in `RunHandle.status`
* and `RunStatusPayload.status`. "abandoned" is applied by server cleanup
* when a handle is reaped without a clean exit ever being observed. */
export type RunStatus = "spawning" | "running" | "completed" | "error" | "killed" | "abandoned";
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */ /** Maps 1:1 to the `claude --permission-mode` CLI flag. */
export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions"; export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions";
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */ /** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max"; export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
/** "running" a live tmux session exists; "gone" it doesn't (killed,
* crashed, claude exited and closed the pane). Computed fresh from tmux
* state on every read, never cached. */
export type RunStatus = "running" | "gone";
/** Body for POST /api/run - parameters for spawning a new `claude` process. */ /** Body for POST /api/run - parameters for starting a lane's terminal run. */
export interface RunStartArgs { export interface RunStartArgs {
/** Initial prompt/task text passed to the CLI. */ laneId: number;
prompt: string;
mode: RunMode;
/** Working directory to launch in; server default applies if omitted. */
cwd?: string; cwd?: string;
/** `--model` value; omitted inherits the CLI's own default (settings.json). */
model?: string; model?: string;
permissionMode?: PermissionMode; permissionMode?: PermissionMode;
/** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */ /** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */
resumeSessionId?: string; resumeSessionId?: string;
effort?: EffortLevel; effort?: EffortLevel;
/** Sent as `claude`'s first positional message once the pane boots; omit
* to just open the pane and let the user type. */
initialPrompt?: string;
} }
/** In-memory (or freshly-fetched) handle for one spawned `claude` process, /** A lane's tmux-backed terminal run one per lane, id is the tmux session
* from POST/GET /api/run - the live counterpart to {@link DashboardRunHistoryItem}. * name (`ccam-lane-<laneId>`). */
* Where {@link DashboardRunHistoryItem} is the persisted DB row (snake_case,
* survives handle reaping), this is the richer live handle (camelCase, carries
* argv/tails/envelope counters) that only exists while the server tracks it. */
export interface RunHandle { export interface RunHandle {
id: string; id: string;
/** OS process id; null before the process has actually spawned. */ laneId: number | null;
pid: number | null;
mode: RunMode;
cwd: string;
model: string | null;
permissionMode: PermissionMode;
effort: EffortLevel | null;
prompt: string;
/** Full argv the server invoked the CLI with, for debugging. */
argv: string[];
resumeSessionId: string | null;
status: RunStatus; status: RunStatus;
/** Epoch-ms timestamp the process was spawned. */ cwd: string | null;
startedAt: number; model: string | null;
/** Epoch-ms timestamp the process exited; null while still running. */ permissionMode: PermissionMode | null;
endedAt: number | null; effort: EffortLevel | null;
exitCode: number | null; resumeSessionId: string | null;
/** POSIX signal that killed the process (e.g. "SIGTERM"); null otherwise. */ /** Claude Code session id this run created/resumed, once known. */
signal: string | null;
error: string | null;
/** Claude Code session id the run created/resumed, once known. */
sessionId: string | null; sessionId: string | null;
/** Count of stream-json envelopes emitted so far. */ /** ISO timestamp the tmux session was created. */
envelopeCount: number; startedAt: string | null;
/** Last chunk of captured stdout, for a quick inline preview. */ /** Initial prompt preview (first 500 chars), null when not provided. */
stdoutTail: string; promptPreview: string | null;
/** Last chunk of captured stderr, for a quick inline preview. */
stderrTail: string;
envelopes?: unknown[]; // present when fetched with ?envelopes=1
} }
/** Response shape of GET /api/run. */ /** Response shape of GET /api/run. */
export interface RunListResponse { export interface RunListResponse {
items: RunHandle[]; items: RunHandle[];
/** Server-configured cap on simultaneously running processes. */
maxConcurrent: number;
/** Count of runs currently in "spawning"/"running" state. */
activeCount: number;
} }
/** /**
@@ -2591,23 +2486,17 @@ export interface RunListResponse {
*/ */
export interface DashboardRunHistoryItem { export interface DashboardRunHistoryItem {
id: string; id: string;
/** Claude Code session id the run created/resumed; null if never captured. */
session_id: string | null; session_id: string | null;
mode: RunMode;
cwd: string; cwd: string;
model: string | null; model: string | null;
permission_mode: PermissionMode | null; permission_mode: PermissionMode | null;
effort: EffortLevel | null; effort: EffortLevel | null;
resume_session_id: string | null; resume_session_id: string | null;
/** Truncated leading excerpt of the original prompt, for the history list. */
prompt_preview: string | null; prompt_preview: string | null;
status: RunStatus; status: "running" | "killed" | "abandoned";
exit_code: number | null; exit_code: number | null;
started_at: string; started_at: string;
ended_at: string | null; ended_at: string | null;
/** True when an in-memory {@link RunHandle} for this row still exists (so
* the UI can offer live actions like "send message"/"kill"); false once
* the handle has been reaped and only the DB row remains. */
isLive: boolean; isLive: boolean;
} }
+54
View File
@@ -0,0 +1,54 @@
/**
* @file splitViewStorage.ts
* @description Persists the Workspace page's split-terminal layout (1/2/4
* panes) and each pane's chosen lane id to localStorage, so the layout
* survives a page reload. Follows the same read/write-with-fallback
* convention as useTheme.ts.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
export type SplitLayout = 1 | 2 | 4;
export interface SplitViewState {
layout: SplitLayout;
paneLaneIds: (number | null)[];
}
const STORAGE_KEY = "ccam.workspace.splitView";
export function defaultSplitViewState(): SplitViewState {
return { layout: 1, paneLaneIds: [null] };
}
function isValidLayout(value: unknown): value is SplitLayout {
return value === 1 || value === 2 || value === 4;
}
function isValidState(value: unknown): value is SplitViewState {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
isValidLayout(v.layout) &&
Array.isArray(v.paneLaneIds) &&
v.paneLaneIds.every((id) => id === null || typeof id === "number")
);
}
export function readSplitViewState(): SplitViewState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultSplitViewState();
const parsed: unknown = JSON.parse(raw);
return isValidState(parsed) ? parsed : defaultSplitViewState();
} catch {
return defaultSplitViewState();
}
}
export function writeSplitViewState(state: SplitViewState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
/* ignore quota / disabled storage */
}
}
+10 -45
View File
@@ -1277,52 +1277,19 @@ export interface UpdateStatusPayload {
fetch_error?: string; fetch_error?: string;
} }
// ───── Interactive run streaming ───── // ───── Terminal run status ─────
// Payloads for the "run a `claude` process from the dashboard" feature. A run is // A lane's terminal run is a tmux session; its own live/dead state is polled
// started via POST /api/run and identified by a `RunHandle` id; the server then // via GET /api/run, not pushed over the WS envelope path. This payload only
// streams stdout envelopes, status transitions, and stdin acks back over the WS. // covers the one thing worth pushing live: a run ending (killed or the pane
// process exiting) so an open Workspace tab can update its badge/switcher
// without polling.
/** Payload for the `run_stream` WebSocket message: one streamed JSON envelope /** Payload for the `run_status` WebSocket message. */
* from a headless/conversation `claude` process started via POST /api/run. */
export interface RunStreamPayload {
/** Id of the `RunHandle` this envelope belongs to. Lets the UI route the chunk
* to the right run panel when several runs stream at once. */
id: string;
/** Raw stream-json envelope emitted by the Claude Code CLI (assistant text
* deltas, tool_use/tool_result blocks, etc.) - shape varies by event type.
* Typed as `unknown` because it's forwarded verbatim and narrowed at render. */
envelope: unknown;
}
/** Payload for the `run_status` WebSocket message: a lifecycle transition for
* a run started via POST /api/run (mirrors `RunHandle.status`). */
export interface RunStatusPayload { export interface RunStatusPayload {
/** Id of the `RunHandle` whose status changed. */ /** The run id (tmux session name, `ccam-lane-<laneId>`). */
id: string; id: string;
/** New run lifecycle state; terminal states are "completed"/"error"/"killed". status: "running" | "gone";
* "spawning" the child is being started; "running" streaming output; /** Epoch-ms timestamp of this transition. */
* "killed" the run was cancelled by the user. */
status: "spawning" | "running" | "completed" | "error" | "killed";
/** Epoch-ms timestamp of this status transition (NOT an ISO string, unlike
* most timestamps in this file). */
at: number;
/** Process exit code; present once status reaches "completed"/"error". 0 means
* a clean exit. */
exitCode?: number;
/** Claude Code session id resumed/created by this run, once known. Lets the UI
* link a run to the {@link Session} it produced. */
sessionId?: string | null;
/** Failure message; present when status is "error". Surfaced in the run panel. */
error?: string;
}
/** Payload for the `run_input_ack` WebSocket message: confirms a message sent
* via POST /api/run/:id/message was written to the child process's stdin. */
export interface RunInputAckPayload {
/** Id of the `RunHandle` the input was delivered to. */
id: string;
/** Echoes the id returned by the `send` call this acks, so the UI can clear
* the matching "sending…" pending state. */
messageId: string;
/** Epoch-ms timestamp the input was delivered (not an ISO string). */
at: number; at: number;
} }
@@ -1680,9 +1647,7 @@ export interface WSMessage {
| DashboardEvent | DashboardEvent
| ImportProgressMessage | ImportProgressMessage
| UpdateStatusPayload | UpdateStatusPayload
| RunStreamPayload
| RunStatusPayload | RunStatusPayload
| RunInputAckPayload
| CcConfigChangedPayload | CcConfigChangedPayload
| AlertEvent | AlertEvent
| WorkflowRun | WorkflowRun
+1 -3
View File
@@ -218,9 +218,7 @@ export function SessionDetail() {
.list() .list()
.then((r) => { .then((r) => {
if (cancelled) return; if (cancelled) return;
const live = r.items.some( const live = r.items.some((h) => h.sessionId === id && h.status === "running");
(h) => h.sessionId === id && (h.status === "running" || h.status === "spawning")
);
setIsDashboardRun(live); setIsDashboardRun(live);
}) })
.catch(() => undefined); .catch(() => undefined);
File diff suppressed because it is too large Load Diff
+62 -1
View File
@@ -7,7 +7,7 @@
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor } from "@testing-library/react"; import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@@ -237,6 +237,12 @@ vi.mock("../../lib/eventBus", () => ({
}, },
})); }));
vi.mock("../../components/run/TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
import { Workspace } from "../Workspace"; import { Workspace } from "../Workspace";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
@@ -627,3 +633,58 @@ describe("Workspace — proof gallery", () => {
expect(gallery).toBeNull(); expect(gallery).toBeNull();
}); });
}); });
describe("split terminal view", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults to a single pane with no layout toggle pressed state implying 2 or 4", async () => {
await renderWorkspace();
expect(screen.getAllByTestId("console-body")).toHaveLength(1);
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
});
it("switching to 2-pane layout renders two independent panes with lane pickers", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(2);
expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2);
});
it("switching to 4-pane layout renders four panes", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
await settle();
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(4);
});
it("persists the layout and pane selections to localStorage across remounts", async () => {
const { unmount } = await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
const selects = screen.getAllByTestId("pane-lane-select");
const select = selects[1];
expect(select).toBeDefined();
fireEvent.change(select!, { target: { value: String(lanesToReturn[1]!.id) } });
await settle();
unmount();
await renderWorkspace();
const persistedSelects = screen.getAllByTestId("pane-lane-select");
expect(persistedSelects).toHaveLength(2);
expect((persistedSelects[1] as HTMLSelectElement).value).toBe(String(lanesToReturn[1]!.id));
});
it("falls back to unselected when a persisted lane id no longer exists", async () => {
localStorage.setItem(
"ccam.workspace.splitView",
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
);
await renderWorkspace();
// Lane 9999 doesn't exist, so it falls back to null (unselected).
// The second pane is already null. Both render as pane-empty.
expect(screen.getAllByTestId("pane-empty")).toHaveLength(2);
});
});
@@ -5762,205 +5762,295 @@ exports[`screen snapshots > Run 1`] = `
</button> </button>
</div> </div>
<div <div
class="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1" class="flex min-h-0 flex-1 gap-4"
data-testid="lane-strip"
>
<p
class="text-sm text-fg-muted"
>
No lanes yet. Create one from a working directory:
<code>
ccam lanes add --cwd $(pwd)
</code>
</p>
</div>
<div
class="flex min-h-0 flex-col gap-2"
> >
<div <div
class="flex min-h-0 flex-1 flex-col gap-5" class="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
data-testid="console-body" data-testid="lane-strip"
> >
<header <p
class="flex items-start gap-3" class="text-sm text-fg-muted"
> >
<div No lanes yet. Create one from a working directory:
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
> <code>
<svg ccam lanes add --cwd $(pwd)
class="lucide lucide-play w-4.5 h-4.5 text-accent" </code>
fill="none" </p>
height="24" </div>
stroke="currentColor" <div
stroke-linecap="round" class="flex min-h-0 flex-1 flex-col gap-2"
stroke-linejoin="round" >
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="6 3 20 12 6 21 6 3"
/>
</svg>
</div>
<div
class="min-w-0 flex-1"
>
<div
class="flex items-center gap-2"
>
<h1
class="text-lg font-semibold text-fg-primary"
>
Run Claude
</h1>
<span
class="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
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
/>
Live
</span>
</div>
<p
class="text-xs text-fg-muted max-w-3xl"
>
Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
</p>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button>
</header>
<div <div
class="rounded-xl border border-border bg-surface-1" class="flex items-center gap-1.5"
> >
<div <button
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]" aria-pressed="true"
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
type="button"
>
1 pane
</button>
<button
aria-pressed="false"
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
type="button"
>
2 pane
</button>
<button
aria-pressed="false"
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
type="button"
>
4 pane
</button>
</div>
<div
class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body"
>
<header
class="flex items-start gap-3"
> >
<div <div
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5" class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
> >
<button <svg
aria-pressed="true" class="lucide lucide-play w-4.5 h-4.5 text-accent"
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent" fill="none"
title="Multi-turn - keep typing follow-ups while the agent works." height="24"
type="button" stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
> >
Conversation <polygon
</button> points="6 3 20 12 6 21 6 3"
<button />
aria-pressed="false" </svg>
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
title="Single prompt, single response. Stdin closes after spawn. — Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits."
type="button"
>
One-shot
</button>
</div> </div>
<div <div
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5" class="min-w-0 flex-1"
> >
<button <div
aria-pressed="true" class="flex items-center gap-2"
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
title="Start a fresh Claude Code session."
type="button"
> >
New session <h1
</button> class="text-lg font-semibold text-fg-primary"
<button >
aria-pressed="false" Run Claude
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary" </h1>
title="Pick a session from your history and continue the conversation. Cwd is locked to the original." <span
type="button" class="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
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
/>
Live
</span>
</div>
<p
class="text-xs text-fg-muted max-w-3xl"
> >
Resume existing session Spin up a Claude Code session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
</button> </p>
</div> </div>
</div> <button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button>
</header>
<div <div
class="px-4 py-3 border-b border-border" class="rounded-xl border border-border bg-surface-1"
> >
<label
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
>
Prompt
</label>
<div <div
class="relative" class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
> >
<div
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
>
<button
aria-pressed="true"
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
title="Start a fresh Claude Code session."
type="button"
>
New session
</button>
<button
aria-pressed="false"
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
type="button"
>
Resume existing session
</button>
</div>
</div>
<div
class="px-4 py-3 border-b border-border"
>
<label
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
>
Prompt
</label>
<textarea <textarea
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y font-sans leading-relaxed" class="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"
placeholder="Ask Claude anything…" placeholder="Ask Claude anything…"
rows="5" rows="5"
spellcheck="false"
/> />
<div
class="mt-1 text-[10px] text-fg-muted"
>
Cmd+Enter / Ctrl+Enter to send
· / for slash commands · @ for file references
</div>
</div> </div>
<div <div
class="mt-1 text-[10px] text-fg-muted" class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
> >
Cmd+Enter / Ctrl+Enter to send <div>
· / for slash commands · @ for file references <label
</div> class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
</div> >
<div Working directory
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4" </label>
>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Working directory
</label>
<div
title="Absolute path. Defaults to the dashboard's own cwd."
>
<div <div
class="relative" title="Absolute path. Defaults to the dashboard's own cwd."
> >
<div <div
class="relative" class="relative"
> >
<div
class="relative"
>
<svg
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
<input
autocomplete="off"
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
placeholder="Type to search or paste an absolute path…"
spellcheck="false"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Model
</label>
<div
class="space-y-1.5"
>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
Inherit from settings
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Permission mode
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
acceptEdits (recommended)
</span>
<svg <svg
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none" fill="none"
height="24" height="24"
stroke="currentColor" stroke="currentColor"
@@ -5972,30 +6062,18 @@ exports[`screen snapshots > Run 1`] = `
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" d="m6 9 6 6 6-6"
/> />
</svg> </svg>
<input </button>
autocomplete="off"
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
placeholder="Type to search or paste an absolute path…"
spellcheck="false"
type="text"
value=""
/>
</div>
</div> </div>
</div> </div>
</div> <div>
<div> <label
<label class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1" >
> Thinking effort
Model </label>
</label>
<div
class="space-y-1.5"
>
<div <div
class="relative" class="relative"
> >
@@ -6006,7 +6084,7 @@ exports[`screen snapshots > Run 1`] = `
<span <span
class="truncate" class="truncate"
> >
Inherit from settings Default (model decides)
</span> </span>
<svg <svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0" class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
@@ -6028,109 +6106,35 @@ exports[`screen snapshots > Run 1`] = `
</div> </div>
</div> </div>
</div> </div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Permission mode
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
acceptEdits (recommended)
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Thinking effort
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
Default (model decides)
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
</div>
<div
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
>
<div <div
class="flex items-center gap-3 text-[11px] min-w-0" class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
/>
<button
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
> >
<svg <div
class="lucide lucide-play w-3.5 h-3.5" class="flex items-center gap-3 text-[11px] min-w-0"
fill="none" />
height="24" <button
stroke="currentColor" class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
stroke-linecap="round" disabled=""
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
> >
<polygon <svg
points="6 3 20 12 6 21 6 3" class="lucide lucide-play w-3.5 h-3.5"
/> fill="none"
</svg> height="24"
Run stroke="currentColor"
</button> stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="6 3 20 12 6 21 6 3"
/>
</svg>
Run
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+7
View File
@@ -16,6 +16,13 @@ import { afterEach, beforeEach } from "vitest";
import "./i18n/index"; import "./i18n/index";
import i18n from "i18next"; import i18n from "i18next";
/** jsdom does not implement ResizeObserver — stub it for components that use it. */
// @ts-expect-error global stub
global.ResizeObserver = class {
observe() {}
disconnect() {}
};
/** Pin locale to English — LanguageDetector may otherwise pick up zh/vi from the host OS. */ /** Pin locale to English — LanguageDetector may otherwise pick up zh/vi from the host OS. */
beforeEach(() => { beforeEach(() => {
i18n.changeLanguage("en"); i18n.changeLanguage("en");
+12 -21
View File
@@ -1468,31 +1468,28 @@ DELETE /api/cc-config/file Body: { scope, type, name? }
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands. Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
### Run Claude ### Run Claude via tmux+PTY
The `/api/run/*` namespace spawns and supervises `claude` subprocesses from the dashboard. Every route enforces a same-origin / loopback-Origin guard; browser requests must come from `localhost`, `127.0.0.1`, `::1`, or `0.0.0.0`. CLI / curl requests with no `Origin` header pass through. When `DASHBOARD_TOKEN` is set, a valid token is also required here (like the rest of `/api/*` — see [Authentication](#authentication)). The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-backed pseudoterminals, one session per lane. The Workspace page renders an interactive xterm.js terminal attached to the session over `/ws-pty/:runId` (binary PTY frames, not JSON). Every route enforces a same-origin / loopback-Origin guard; browser requests must come from `localhost`, `127.0.0.1`, `::1`, or `0.0.0.0`. CLI / curl requests with no `Origin` header pass through. When `DASHBOARD_TOKEN` is set, a valid token is also required here (like the rest of `/api/*` — see [Authentication](#authentication)). **`tmux` must be installed on the dashboard server** (same operational requirement as better-sqlite3's native module).
```http ```http
GET /api/run List all handles + concurrency state GET /api/run List all live runs (computed fresh from tmux state)
GET /api/run/tmux { available: bool } — whether tmux is on PATH
GET /api/run/binary { found, path } for the `claude` binary GET /api/run/binary { found, path } for the `claude` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent) GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor) (skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Spawn — Body: { prompt, mode, cwd?, model?, permissionMode?, resumeSessionId?, effort? } POST /api/run Start/attach run — Body: { laneId, initialPrompt?, ... }
POST /api/run/:id/message Send follow-up turn — Body: { text } GET /api/run/:id Run handle (returns live run state)
GET /api/run/:id[?envelopes=1] Handle state; ?envelopes=1 includes the in-memory envelope log DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
DELETE /api/run/:id Stop (SIGTERM → SIGKILL after 5 s)
``` ```
`mode` is `"headless"` (single-shot, stdin closed after spawn, prompt in argv via `-p`) or `"conversation"` (multi-turn, stdin stays open, prompt and follow-ups piped as stream-json envelopes). `resumeSessionId` requires conversation mode and adds `--resume <id>` so the run continues an existing Claude Code session — the cwd is locked to the original session's cwd. **When `resumeSessionId` is set, `prompt` may be empty** — the spawner skips the initial stdin write and `claude --resume` idles on the resumed conversation until the user posts a follow-up via `POST /api/run/:id/message`. Headless mode and fresh conversations still require a non-empty prompt (`EBADPROMPT` otherwise). `effort` (`"low"` / `"medium"` / `"high"`) maps to `--effort` and tunes the model's thinking budget. The spawner always passes `--output-format stream-json --verbose --include-partial-messages` so output streams over the existing dashboard WebSocket as `run_stream` (parsed envelopes, including `stream_event` deltas for character-by-character rendering), `run_status` (status transitions), and `run_input_ack` (stdin write confirmed). Concurrency is effectively uncapped (default ceiling 10000, override with `RUN_MAX_CONCURRENT`) — the terminal TUI has no cap and neither does the dashboard; the ceiling exists only to prevent fork-bomb footguns from a buggy client. **`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `ccam-lane-<id>` in the lane's working directory. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
Every history row carries `lane_id`: the lane the run was started through **PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `ccam lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
(`POST /api/lanes/:id/start`), or `null` for a run spawned straight from
`POST /api/run`. `GET /api/run/history?laneId=<n>` returns only that lane's
runs, which is what the Workspace page's per-lane history lists.
Spawned `claude` processes 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 Run page itself just owns the live streaming UX. Every history row carries `lane_id`: the lane whose run it belongs to. `GET /api/run/history?laneId=<n>` returns only that lane's runs. Spawned `claude` processes 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.
--- ---
@@ -1672,15 +1669,9 @@ Sent when a notification is created.
} }
``` ```
#### run_stream / run_status / run_input_ack #### /ws-pty/:runId — PTY frames
Broadcast by `routes/run.js` and `lib/run-spawner.js` for `/run` page subprocesses. `run_stream.data.envelope` is a parsed stream-json envelope; the spawner runs claude with `--include-partial-messages` so this includes `stream_event` deltas (`message_start`, `content_block_delta` text/thinking deltas, `message_stop`, etc.) for character-level streaming. A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell`, other tools), all receiving the same frames live-synced. Resize events: the client sends a `TIOCSWINSZ` ioctl down the pane's pty when the browser terminal is resized, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes.
```json
{ "type": "run_stream", "data": { "id": "<run-id>", "envelope": { "type": "stream_event", "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } } } } }
{ "type": "run_status", "data": { "id": "<run-id>", "status": "running", "at": 1700000000000 } }
{ "type": "run_input_ack", "data": { "id": "<run-id>", "messageId": "<uuid>", "at": 1700000000000 } }
```
#### cc_config_changed #### cc_config_changed
+11 -45
View File
@@ -309,7 +309,8 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
- **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human. - **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human.
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment. - **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
- **Console**`RunSetup`, `RunConsole` and `RunHistory` behind a disclosure that **starts collapsed**. Watching lanes is the default posture; driving one is the exception. Collapsing hides the console with CSS and never unmounts it, so a live run keeps its rendered history and scroll position. - **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
- **Split view** — a layout toggle (1 / 2 / 4 panes) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`ccam.workspace.splitView`) across reloads.
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row. - **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`. Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
@@ -375,24 +376,6 @@ three times, and the lane list is polled and re-broadcast on every hook-driven
tool call an agent makes. The browser fetches it per card instead, on mount and tool call an agent makes. The browser fetches it per card instead, on mount and
every 30 seconds, and a failed fetch is silent. every 30 seconds, and a failed fetch is silent.
### Running and releasing lanes
The Workspace page starts runs through `POST /api/lanes/:id/start`, which accepts the same `mode` and `effort` parameters as `POST /api/run`:
```bash
curl -X POST http://localhost:4820/api/lanes/5/start \
-H "Content-Type: application/json" \
-d '{"mode": "conversation", "effort": "medium"}'
```
**A finished run releases its lane.** When a spawned Claude Code process exits (normally, non-zero, killed, or never spawned), the lane's `run_id` is cleared, its `status` returns to `idle`, and the change is broadcast as a `lane_update` WebSocket message. This prevents lanes from sitting at `running` with a dead child.
Runs started through a lane are recorded with the lane's id and are history-queryable:
```bash
curl http://localhost:4820/api/run/history?laneId=5
```
## Viewing lanes ## Viewing lanes
List all lanes with their current status: List all lanes with their current status:
@@ -433,6 +416,10 @@ or from the dashboard: the pipeline-template select next to the lane's title in
Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the switch warns when that happens (both CLI and UI), and the next `ccam stage` fixes it. Switching re-resolves the lane's existing declared `stage` against the new node list. A stage the old template knew may resolve to nothing in the new one; the switch warns when that happens (both CLI and UI), and the next `ccam stage` fixes it.
## Attaching a real terminal to a lane
`ccam lanes shell` attaches a real terminal to the exact tmux session the dashboard's Start/Resume buttons use for this lane (`ccam-lane-<id>`), creating it if it doesn't exist yet. Type `claude` inside it like any normal terminal session — the dashboard's Workspace terminal view is just another client attached to the same tmux session, so both stay in sync live.
**In practice you never have to pick correctly at creation.** The template only has to match whichever skill is actually driving the lane, and the skill enforces that itself: `ship-feature-lane` runs `ccam lanes pipeline ship-feature` and `ship-feature` runs `ccam lanes pipeline default` before their first `ccam stage` call — a no-op if the lane is already on that template, a self-correction if it isn't. A human chatting with the lane never needs to open the picker; whichever skill gets invoked decides the template. **In practice you never have to pick correctly at creation.** The template only has to match whichever skill is actually driving the lane, and the skill enforces that itself: `ship-feature-lane` runs `ccam lanes pipeline ship-feature` and `ship-feature` runs `ccam lanes pipeline default` before their first `ccam stage` call — a no-op if the lane is already on that template, a self-correction if it isn't. A human chatting with the lane never needs to open the picker; whichever skill gets invoked decides the template.
The dashboard renders every node in the pipeline in one of five **states**: The dashboard renders every node in the pipeline in one of five **states**:
@@ -931,38 +918,17 @@ The web UI and CLI provide these actions on a lane:
### start ### start
Launch a new Claude Code session bound to the lane. If the lane has a recorded `session_id` from a previous run, you can resume it instead with `--resume`. Start a tmux-backed run attached to the lane. The Workspace page's "Start Run" button calls `POST /api/run` with the lane's id; you can also attach from a real terminal using `ccam lanes shell`.
```bash ```bash
curl -X POST http://localhost:4820/api/lanes/5/start \ curl -X POST http://localhost:4820/api/run \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"prompt": "continue the work", "resume": true}' -d '{"laneId": 5, "initialPrompt": "continue the work"}'
``` ```
`mode` accepts the same two values as `POST /api/run``"conversation"` (the The `initialPrompt` field is optional — if omitted, the tmux session is created/attached with no initial input, and you type into the terminal directly. Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The same tmux session persists across attach/detach cycles, so you can switch between the browser Workspace and `ccam lanes shell` seamlessly.
default: multi-turn, `message` keeps working) or `"headless"` (one shot, the
prompt goes in argv and the process exits when the turn finishes). Unlike
`POST /api/run`, an unknown value is refused with `400 EBADMODE` rather than
silently treated as a conversation.
**A finished run releases its lane.** When the child truly exits — normally, **A killed run releases the lane.** When the tmux session is terminated (from the terminal, browser stop button, or external `kill`), the lane's `run_id` is cleared, its status returns to `idle`, and the change is broadcast as `lane_update`. Runs are recorded with the lane's id and are listable via `GET /api/run/history?laneId=<n>`.
non-zero, killed, or never spawned at all — the lane's `run_id` is cleared, its
status returns to `idle`, and the change is broadcast as `lane_update`. So a
lane never sits at `running` behind a dead run, and `message` reports
`409 ENORUN` instead of targeting one. Runs started this way are recorded with
the lane's id and are listable via `GET /api/run/history?laneId=<n>`.
Currently the `prompt` field does not populate the input field in the UI (see "Known limitations" below).
### message
Send input to a running session. The lane must have a live `run_id` (active session). The `needs_action` flag clears automatically when the message is delivered.
```bash
curl -X POST http://localhost:4820/api/lanes/5/message \
-H "Content-Type: application/json" \
-d '{"text": "approved, proceed"}'
```
### stop ### stop
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,242 @@
# Replace "Run Claude from the browser" with a real tmux+PTY terminal
**Status:** approved 2026-08-11.
## Problem
The current Run feature (`server/lib/run-spawner.js`, `server/routes/run.js`,
`client/src/components/run/*`) spawns `claude --output-format stream-json`,
parses the structured JSON event stream, and renders it as custom chat
bubbles (`RunConsole.tsx`). This has two gaps the user hit:
1. A session started directly in a terminal (`claude`, no dashboard
involvement) can never appear in the dashboard's active-runs UI or be
controlled from it — hooks are one-way, fire-and-forget; there is no
channel to inject keystrokes into a process the dashboard didn't spawn.
2. Even for dashboard-spawned runs, the rendered UI is a re-implementation of
Claude Code's own TUI (chat bubbles, tool-call cards) rather than the real
thing — spinners, `/` command menus, permission prompts, and any other TUI
surface only exist if `RunConsole.tsx` was specifically coded to parse and
render that JSON event.
The user wants: type `claude` for real (interactive TUI, not
`--output-format stream-json`), and still get full two-way control (start,
resume, send input, kill) from the dashboard, AND be able to drop into the
exact same live session from a real terminal at any time.
## Approach
**tmux is the only viable mechanism.** A dashboard-controlled process cannot
inject keystrokes into another process's stdin without owning that stdin.
tmux already solves "one pane, multiple attached clients, all synced" —
attaching a second client (the dashboard's PTY) to the same tmux session as
the user's real terminal gives real two-way control with zero custom sync
code. The dashboard both **creates** the tmux session (so Start/Resume work
as one-click actions) and can be attached to **from** a real terminal by
name, satisfying "type `claude` for real."
This **replaces** the stream-json run mechanism entirely — no dual mode.
Cost: real TUI rendering needs a real terminal emulator in the browser
(`@xterm/xterm`), which this repo does not have today; `node-pty` is needed
server-side to get a real PTY for the attach client (a plain `child_process`
pipe is not a TTY, and `tmux attach` behaves differently — cursor
positioning, terminal size queries — without one). Both are new, justified
dependencies (no stdlib/native equivalent renders ANSI/TUI output).
The **structured session view is not rebuilt**`claude` still fires the
same hooks (`SessionStart`, `PreToolUse`, …) it always does regardless of how
it's invoked, so the existing session/agent/event tables and their WS
broadcasts (`session_updated`, `agent_updated`) already populate live,
independent of the terminal view. Nothing new is needed to keep them in
sync — they were never coupled to the run mechanism in the first place.
## Scope
**In scope:**
- tmux session lifecycle (create/attach/resume/kill) per lane, named
`ccam-lane-<id>`.
- A dedicated WebSocket path streaming a real PTY (`node-pty` running
`tmux attach-session`) to the browser, rendered with `@xterm/xterm`.
- Replacing `RunConsole.tsx` with a `TerminalView.tsx` component.
- Reusing (not rebuilding) `RunSetup.tsx`'s cwd/model/permission-mode/effort
pickers and `RunHistory.tsx`/`ActiveRunsSwitcher`'s multi-run list, both
adjusted to the new data source.
- `ccam lanes shell` — CLI convenience to attach a real terminal to the same
named tmux session.
- `dashboard_runs` migration: drop `mode` (headless/conversation no longer
applies — a live pane is always interactive), add `tmux_session`.
- A tmux-availability check surfaced the same way the existing
"`claude` not on PATH" check is (`api.run.binary()` precedent).
**Out of scope (explicitly):**
- Any fallback to the old stream-json mode. It is deleted, not kept behind a
flag.
- Resize handling beyond fit-to-container on load and on browser window
resize (no manual pane-splitting, no multi-pane tmux layouts).
- Any change to hook ingestion, session/agent tables, or their WS broadcasts
— they already work unmodified.
- CI running real tmux — server tests mock the tmux/PTY layer (see Testing).
## Design
### Session naming and lifecycle
One tmux session per lane, name `ccam-lane-<lane.id>` — stable, collision-free
(numeric lane id, not a user-editable slug).
- **Start:** `tmux has-session -t ccam-lane-<id>` (exit code only, no
output). If absent: `tmux new-session -d -s ccam-lane-<id> -c <lane.cwd> --
claude <argv...> [initialPrompt]` — `claude` runs directly as the pane's
command (nothing is "typed"). `argv` carries `--model`, `--permission-mode`,
`--effort` as today; an optional initial prompt is passed as a trailing
**positional** argument (not `-p`, which forces print-and-exit and closes
stdin) — `claude` treats a bare positional as the first turn's message and
stays interactive afterward, so no timing-dependent "wait then type" step
is needed.
If already present: no-op — this is the existing repo convention (adopt an
already-live server instead of double-binding; see `server/index.js`'s
port-adoption logic) applied to tmux sessions.
- **Resume:** identical, `argv` includes `--resume <session_id>`.
- **Kill:** `tmux kill-session -t ccam-lane-<id>`. This sends SIGHUP to the
pane's process group. Whether `claude` treats that as a clean shutdown
(firing `SessionEnd`) is unverified — flag for implementation to check; if
not, this repo's existing dead-session liveness reap (`server/lib/
session-liveness.js`, referenced in `CLAUDE.md`) is the safety net that
already exists for exactly this kind of gap, no new code needed.
- **List (`GET /api/run`):** no longer a Map read. Runs `tmux list-sessions
-F '#{session_name}'`, filters the `ccam-lane-` prefix, and joins against
`dashboard_runs` rows (started_at, model, lane_id, …) for display. This
makes "which runs are active" a **computed fact from tmux state**, not
cached server memory — the same principle this repo already applies to
lane runtime up/down (`CLAUDE.md`: "computed fact, never a stored one"). A
tmux session killed by an out-of-band `kill`, OOM, or reboot self-corrects
on the next list call instead of leaving a ghost "running" row.
### `server/lib/pty-run.js` (replaces `run-spawner.js`)
Same exported surface where it still makes sense, so `routes/run.js`'s call
sites don't need a rewrite beyond the changed body:
- `spawnRun({laneId, cwd, model, permissionMode, effort, resumeSessionId, initialPrompt})`
— runs the has-session/new-session dance above, records the row via
`dashboard-runs.js`, returns `{id: tmuxSessionName, ...}`.
- `killRun(id)``tmux kill-session`.
- `listRuns()``tmux list-sessions` + DB join, as above.
- `attachStream(id, {cols, rows})` — new: spawns
`node-pty.spawn("tmux", ["attach-session", "-t", id], {cols, rows})` and
returns the PTY handle for a WS connection to pipe.
- `sendInput` / raw envelope buffering / `MAX_ENVELOPES_PER_HANDLE` /
`handles` Map / reap timers — all deleted; state lives in tmux, not this
process's memory. No 5-minute reap needed either — a tmux session survives
the dashboard restarting, by design.
### WebSocket transport (new path, existing `/ws` untouched)
`server/websocket.js` currently owns one `WebSocketServer({path: "/ws"})`.
A second server is added for the PTY stream, same `verifyClient` auth guard
(Host allowlist + `DASHBOARD_TOKEN`) reused as-is:
- Path: `/ws-pty/:runId` (runId = tmux session name, validated against the
`ccam-lane-<numeric id>` pattern before any tmux command touches it — this
is the trust boundary: without validation, a WS client could name an
arbitrary tmux session on the host and attach to something unrelated to
this dashboard).
- **Binary frames** = raw PTY bytes, both directions (server→client is
`pty.onData`, client→server is keystrokes written straight to `pty.write`).
- **Text frames** = JSON control messages, distinguishable from binary frames
natively by `ws``{"type":"resize","cols":N,"rows":N}` on window
resize/mount, `{"type":"exit","code":N}` sent once when the attach PTY
closes (pane process exited or session was killed).
- One `node-pty` attach process per WS connection — multiple browser tabs
attach as independent tmux clients to the same session; tmux itself keeps
them in sync (this is exactly the tmux feature the whole design leans on).
Closing a tab just ends that one attach client; the tmux session and the
`claude` process underneath are untouched (detach-safe by construction).
### Client
- **New `client/src/components/run/TerminalView.tsx`** replaces
`RunConsole.tsx`: mounts `@xterm/xterm` + `@xterm/addon-fit`, opens
`/ws-pty/<runId>`, writes incoming binary frames to the terminal, writes
terminal keystrokes (`term.onData`) to the WS as binary frames, sends a
`resize` control message on mount and on `ResizeObserver` fire.
- **`RunSetup.tsx`** keeps its cwd/model/permission-mode/effort/resume
pickers and file-mention autocomplete as-is; the prompt textarea becomes
optional ("send this once the terminal is up" instead of "the one-shot
headless prompt"); its submit calls the same `api.lanes.action(...,
"start", {...})` / `api.run.start()` shape, just against the new backend.
- **`RunHistory.tsx` / `ActiveRunsSwitcher`** keep their list/switch/kill
UI; the data they render (`api.run.list()`, `api.run.history()`) changes
shape server-side but not their consumption pattern.
- **`client/src/lib/api.ts`**: `api.run.send(id, text)` is removed — the
initial prompt now travels as a positional `spawnRun` argument (see
above), and every input after that goes through the WS binary channel
directly from `TerminalView`, not a REST call.
- **Removed:** `useRunStream` hook (built for the envelope array this design
no longer produces), `RunConsole.tsx`. `server/lib/stream-json-parser.js`
and the envelope types in `client/src/lib/types.ts` are removed too, after
a grep confirms no other caller depends on them (SessionDetail's
transcript viewer reads pre-ingested JSONL from `~/.claude`, a completely
separate code path — expected to be unaffected, but verify before
deleting).
### `ccam lanes shell`
`bin/ccam.js`, same dispatch pattern as the other `lanes` subcommands
(`rest[0] === "shell"``cmdLanesShell`). Resolves the lane from `cwd` the
same way `ccam stage` does, computes `ccam-lane-<id>`, and `execve`s (replace
the current process image, not a child — so Ctrl-C/signals behave like a
normal terminal command) `tmux new-session -A -s ccam-lane-<id> -c
<lane.cwd>`. `-A` creates-or-attaches, so this is the exact same idempotent
behavior as clicking Start on the dashboard. The user types `claude`
themselves inside — this command's only job is getting them into the right
named session, nothing about `claude` itself.
### Dependencies
- Server: `node-pty` (native module, same operational category as
`better-sqlite3` — needs a build toolchain or prebuilt binary; this repo
already documents that tradeoff for `better-sqlite3` in `INSTALL.md`/
`SETUP.md`, follow the same pattern for `node-pty`).
- Client: `@xterm/xterm`, `@xterm/addon-fit`.
- System: `tmux` on the host running the server. Not installable via npm —
add a startup/on-demand check (`which tmux` or `tmux -V`) mirroring the
existing `api.run.binary()` "claude not on PATH" banner pattern, surfaced
in the UI before Start is attempted. Docker image (`Dockerfile`, alpine
base) needs `RUN apk add --no-cache tmux` added.
### `dashboard_runs` migration
Drop `mode` (headless/conversation distinction no longer exists — every run
is a live interactive pane). Add `tmux_session TEXT`. Everything else
(`session_id`, `model`, `permission_mode`, `effort`, `resume_session_id`,
`prompt_preview`, `status`, `exit_code`, `started_at`, `ended_at`, `lane_id`)
is unchanged — these are still meaningful metadata about the `claude`
invocation regardless of transport.
## Testing
- **Server:** unit tests for `pty-run.js` mock `node-pty` and the
`child_process`/`execFile` calls used for `tmux has-session` /
`new-session` / `kill-session` / `list-sessions` (matching this repo's
existing pattern in `worktree.js`'s tests, which mock `execFile("git",
...)` the same way) — no real tmux exec in the suite. CI has no `tmux`
installed (confirmed: no existing CI workflow, alpine Docker base lacks
it), so this mocking is required, not optional.
- **Client:** `TerminalView.test.tsx` mocks the WS connection and asserts
binary frames get written to a mocked `xterm` instance; `xterm.js` needs a
canvas — check whether the existing jsdom test setup handles this or
needs `@xterm/xterm`'s documented headless/test workaround before writing
the test.
- `screens.snapshot.test.tsx` will need regenerating after Workspace's run
UI changes — review the diff, don't blindly accept it, per this repo's
testing policy.
## Verify
`npm run test:server`, `npm run test:client` (including the regenerated
snapshot), and a manual click-through: install `tmux` locally, Start a run
from Workspace, confirm the real Claude Code TUI renders in the browser,
type in the browser and confirm it reaches the pane, run `ccam lanes shell`
from a real terminal for the same lane and confirm it drops into the exact
same live session, kill from the dashboard and confirm the tmux session and
row both clear.
@@ -0,0 +1,74 @@
# Split terminal view for the Workspace console
**Status:** approved 2026-08-14.
## Problem
`Workspace.tsx` renders exactly one lane's run console at a time: a single
`RunSetup`/`TerminalView` switcher (client/src/pages/Workspace.tsx:789-824)
driven by page-level state (`selectedLaneId`, `prompt`, `cwd`, `model`,
`permissionMode`, `effort`, `resumeSession`, `handle`, `busy`, `activeRuns`,
`runHistory`, `cwdSuggestions`). Lanes are independent working directories
that can each have their own live tmux/PTY session running concurrently on
the server (`server/lib/pty-attach.js`), but the dashboard can only show one
at a time — comparing two lanes' output means switching back and forth.
The user wants to view multiple lanes' terminals side by side: 1 pane (today's
behavior), 2 panes (left/right), or 4 panes (2x2 grid).
## Approach
**Extract a self-contained `LaneConsolePane` component.** Move the existing
RunSetup/TerminalView switcher and all its state out of `Workspace.tsx` into
its own component that owns one lane's run lifecycle independently. Each
pane gets its own `laneId` (chosen via a dropdown in the pane header, listing
all lanes, not just ones with an active run) and manages its own
prompt/cwd/model/permissionMode/effort/resumeSession/handle/busy/activeRuns/
runHistory state — nothing is shared across panes.
Workspace keeps a `paneLaneIds: (number | null)[]` array sized to the current
layout (1, 2, or 4) and renders that many `LaneConsolePane` instances in a
CSS grid. This is the only viable approach given the existing state model is
single-lane; the alternative (keeping one shared state object indexed by
lane) would require rewriting every handler in Workspace.tsx to be
lane-aware and is a much larger, riskier diff for the same result.
## Layout
A layout toggle (1 / 2 / 4 buttons) sits next to the existing console
header. Grid via CSS:
- **1**: full width — identical to today.
- **2**: `grid-cols-2` — left/right.
- **4**: `grid-cols-2 grid-rows-2` — four corners.
Each pane has a small header with a lane-select dropdown. If the selected
lane has no active run, the pane shows a compact `RunSetup` (reused
component, same as today's pre-run form) so the user can start one directly
from the pane. If it has an active run, the pane shows `TerminalView` as
today.
## Persistence
The chosen layout mode and each pane's selected `laneId` are saved to
`localStorage` (e.g. key `ccam.workspace.splitView`) and restored on next
visit to Workspace. If a persisted lane no longer exists, that pane falls
back to unselected (dropdown placeholder).
## Non-goals
- No server/API changes — this is purely a client-side rendering feature.
Each lane's run already exists independently server-side; this just lets
the UI display more than one at once.
- No synchronized input across panes (typing in one pane's terminal does not
affect others) — each `TerminalView` keeps its own independent WebSocket
connection, unchanged from today's single-instance behavior.
## Testing
- `client/src/pages/__tests__/Workspace.test.tsx` currently mocks
`TerminalView` and exercises the single-console flow; update it (or add a
sibling test file) to cover: layout toggle, per-pane lane dropdown,
starting a run from within a pane, and multiple panes rendering
independent `TerminalView`/`RunSetup` instances.
- Run `npm run test:client` before considering this done.
+93
View File
@@ -10,6 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
@@ -81,6 +82,98 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@lydell/node-pty": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.2.0-beta.15.tgz",
"integrity": "sha512-Br8wBxzbxFwdWgk9uQ+rdzE0xfoxOK4QuGH54swhRwc5IxP6H9Y1/bcyazRGvNUs6XkB5qNVkezuKSRxUwZe7A==",
"license": "MIT",
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.15",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.15",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.15",
"@lydell/node-pty-linux-x64": "1.2.0-beta.15",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.15",
"@lydell/node-pty-win32-x64": "1.2.0-beta.15"
}
},
"node_modules/@lydell/node-pty-darwin-arm64": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.2.0-beta.15.tgz",
"integrity": "sha512-6TSBbzdcLiNTHl1mTuzflqXrkmcC36USVGvERoDgvHk2ItEDaMaFZuAJ1CqPmwYj0DyhCS16TVS8OGK9xZnjyQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@lydell/node-pty-darwin-x64": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.2.0-beta.15.tgz",
"integrity": "sha512-yDT2oqPqYMBScyuk1U9Rg5VKcrbMOD9o9jWYYamDADA3NSbUISroPChrqYRQ74Y7BQtNH4gqYAiWOZRi5uQZ0Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@lydell/node-pty-linux-arm64": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.2.0-beta.15.tgz",
"integrity": "sha512-wkbNF7dYAmtJv+o2+iztVlNwnUB4B0uX0wh/UD+mwMcmE2gNMnW9GChXO7fEE5XJokD0vB5idiHpGegaN+G/sg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@lydell/node-pty-linux-x64": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.2.0-beta.15.tgz",
"integrity": "sha512-+U/5AVvHT6W+8OCYcnJgN0Qgc0ycO3TfD6aaFJHK+WHij797f8gsi5dV1HEO9l6YQmWCD+VL5gaLDhx3mxHwCA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@lydell/node-pty-win32-arm64": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.2.0-beta.15.tgz",
"integrity": "sha512-pyAk91w7wnnKrD4mrHXtIXRfmzSWV5bEzvRhurXcMCtCc2TJ424ciUskIgWMhAPP6y3KyUnqElj+U6kY3iOt0A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@lydell/node-pty-win32-x64": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.2.0-beta.15.tgz",
"integrity": "sha512-2f8twEmDVxZ7drchAXjtevpmSPhFok0avAnzXro4t5gmz0xsPNKkoZvymwtuIS3xo7PzQqZOPQ/YzwEMb7oIzQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@nodable/entities": { "node_modules/@nodable/entities": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
+1
View File
@@ -89,6 +89,7 @@
"docker:down": "docker compose down" "docker:down": "docker compose down"
}, },
"dependencies": { "dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
+10 -11
View File
@@ -1,19 +1,19 @@
--- ---
description: Build the dashboard UI if needed and print its URL description: Rebuild the dashboard UI and print its URL
--- ---
Build the dashboard bundle if it is not there yet, then print the URL. The Rebuild the dashboard bundle, then print the URL. Always forces a rebuild so a
bootstrap already builds it on session start, so this is usually a no-op — use stale bundle (e.g. after a fix commit landed but the bootstrap's build predates
it to force a rebuild, or to finish the build if the bootstrap's own attempt it) never serves silently. Also finishes the build if the bootstrap's own
failed (check `~/.claude/agent-dashboard/runtime/client-build.log`). attempt failed (check `~/.claude/agent-dashboard/runtime/client-build.log`).
```bash ```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-open.js" node "${CLAUDE_PLUGIN_ROOT}/scripts/plugin-open.js" --force
``` ```
The first run installs the client toolchain and takes a few minutes; later runs Install + build takes a few minutes if the client toolchain isn't already
print the URL immediately. No server restart is needed — the server already installed; otherwise the rebuild itself takes ~10-15s. No server restart is
serves from that directory. needed — the server already serves from that directory.
Then help the user open it: Then help the user open it:
@@ -25,5 +25,4 @@ uname -s
- `Linux``xdg-open <url>` - `Linux``xdg-open <url>`
- otherwise → tell them to open the URL in a browser. - otherwise → tell them to open the URL in a browser.
Keep the output to a few lines. Pass `--force` to the script only if the user Keep the output to a few lines.
asks for a rebuild.
+191 -242
View File
@@ -24,7 +24,7 @@ const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lifecycle-"));
process.env.LANES_ROOT = path.join(ROOT, "lanes"); process.env.LANES_ROOT = path.join(ROOT, "lanes");
const { createApp, startServer } = require("../index"); const { createApp, startServer } = require("../index");
const runs = require("../lib/run-spawner"); const runs = require("../lib/pty-run");
let server; let server;
let BASE; let BASE;
@@ -80,6 +80,29 @@ function makeRunChild({ exitsOnKill }) {
return child; return child;
} }
// Puts a fake `claude` binary on PATH so a real `/start` spawns a real tmux
// session running THIS script instead of the system Claude Code CLI. Tests
// that mock tmux's own exec calls (to simulate a stuck/live session) still
// spawn this real process underneath — without the stub, that spawn launches
// the actual `claude` binary and, because the mock replaces the app's own
// kill-session call, the real process is never actually terminated, leaking
// a live tmux session + CLI process for good. Returns the restore function.
function stubClaudeBinary(name) {
const bin = path.join(ROOT, `${name}-bin`);
const claude = path.join(bin, "claude");
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(
claude,
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
);
fs.chmodSync(claude, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
async function waitForProvisioning(id) { async function waitForProvisioning(id) {
const deadline = Date.now() + 5000; const deadline = Date.now() + 5000;
let response; let response;
@@ -774,7 +797,7 @@ describe("destructive lane lifecycle actions", () => {
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
}); });
it("waits for the run-spawner child's actual exit before resetting its worktree", async () => { it("waits for the tmux session to exit before resetting its worktree", async () => {
const lane = await createManagedLane("await-real-exit"); const lane = await createManagedLane("await-real-exit");
fs.writeFileSync(path.join(lane.cwd, "written-by-run.txt"), "run output\n"); fs.writeFileSync(path.join(lane.cwd, "written-by-run.txt"), "run output\n");
const bin = path.join(ROOT, "run-exit-bin"); const bin = path.join(ROOT, "run-exit-bin");
@@ -803,7 +826,7 @@ describe("destructive lane lifecycle actions", () => {
expect: destructiveExpect(preflight.body), expect: destructiveExpect(preflight.body),
}); });
assert.equal(reset.status, 200); assert.equal(reset.status, 200);
assert.notEqual(runs.getRun(runId).actualExitedAt, null); assert.equal(runs.getRun(runId).status, "gone");
assert.equal(fs.existsSync(path.join(lane.cwd, "written-by-run.txt")), false); assert.equal(fs.existsSync(path.join(lane.cwd, "written-by-run.txt")), false);
} finally { } finally {
process.env.PATH = originalPath; process.env.PATH = originalPath;
@@ -811,58 +834,57 @@ describe("destructive lane lifecycle actions", () => {
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
}); });
it("resets after a lane run fails to spawn because that handle is already exited", async () => { it("returns ERUNTIMEOUT and leaves the worktree untouched when a tmux session never exits", async () => {
const lane = await createManagedLane("failed-spawn-reset"); const tmux = require("../lib/tmux");
const originalPath = process.env.PATH; const lane = await createManagedLane("await-timeout");
const emptyBin = path.join(ROOT, "empty-bin"); const sentinel = path.join(lane.cwd, "must-survive-timeout.txt");
fs.mkdirSync(emptyBin, { recursive: true }); fs.writeFileSync(sentinel, "still here\n");
process.env.PATH = emptyBin;
try {
const started = await request("POST", `/api/lanes/${lane.id}/start`, {
prompt: "cannot spawn",
});
assert.equal(started.status, 200);
const runId = started.body.lane.run_id;
const deadline = Date.now() + 1000;
while (!runs.getRun(runId).actualExitedAt && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.notEqual(runs.getRun(runId).actualExitedAt, null);
process.env.PATH = originalPath;
// Start a run for the lane
const restorePath = stubClaudeBinary("await-timeout");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
assert.equal(started.status, 200);
const runId = started.body.lane.run_id;
// Mock tmux so has-session always returns 0 (session exists), simulating a stuck session
tmux.__setExecImpl(async (cmd, args) => {
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
return { code: 0, stdout: "", stderr: "" };
}
if (cmd === "tmux" && args[0] === "list-sessions") {
return { code: 0, stdout: "", stderr: "" };
}
if (cmd === "tmux" && args[0] === "kill-session" && args[1] === "-t" && args[2] === runId) {
return { code: 0, stdout: "", stderr: "" };
}
return { code: 1, stdout: "", stderr: "" };
});
try {
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`); const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, { const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true, confirm: true,
force: true, force: true,
expect: destructiveExpect(preflight.body), expect: destructiveExpect(preflight.body),
}); });
assert.equal(reset.status, 200); assert.equal(reset.status, 500);
assert.equal(reset.body.error.code, "ERUNTIMEOUT");
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
} finally { } finally {
process.env.PATH = originalPath; tmux.__reset();
// The mocked kill-session above only fools the app's own check — the
// real tmux session + claude stub spawned above is still alive and
// must be killed for real, or it leaks past this test run.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
}); });
it("returns ERUNTIMEOUT and leaves the worktree untouched when a run never exits", async () => {
const lane = await createManagedLane("await-timeout");
const sentinel = path.join(lane.cwd, "must-survive-timeout.txt");
fs.writeFileSync(sentinel, "still here\n");
const child = makeRunChild({ exitsOnKill: false });
const handle = runs.__injectChildForTest({ child });
await request("PATCH", `/api/lanes/${lane.id}`, { run_id: handle.id });
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=reset`);
const reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
confirm: true,
force: true,
expect: destructiveExpect(preflight.body),
});
assert.equal(reset.status, 500);
assert.equal(reset.body.error.code, "ERUNTIMEOUT");
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
await request("DELETE", `/api/lanes/${lane.id}`);
});
it("removes a lane whose worktree was deleted by hand, taking the prune path", async () => { it("removes a lane whose worktree was deleted by hand, taking the prune path", async () => {
// The design promises "the lane reports `missing` and only `remove` is // The design promises "the lane reports `missing` and only `remove` is
// offered, taking the prune path". Before this, `remove` hit check 2, which // offered, taking the prune path". Before this, `remove` hit check 2, which
@@ -892,29 +914,52 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), ""); assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), "");
}); });
it("refuses a second start while the first run is still live, so no child is orphaned", async () => { it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => {
// Overwriting run_id while its child is alive orphans that child: a later // Overwriting run_id while its tmux session is alive orphans that session: a later
// reset kills and awaits only the RECORDED run, then `git clean -fd` the // reset kills and awaits only the RECORDED run's session, then `git clean -fd` the
// directory the orphan is still writing into. // directory the orphan session is still using.
const tmux = require("../lib/tmux");
const lane = await createManagedLane("start-twice"); const lane = await createManagedLane("start-twice");
const child = makeRunChild({ exitsOnKill: true });
const handle = runs.__injectChildForTest({ child });
await request("PATCH", `/api/lanes/${lane.id}`, { run_id: handle.id });
assert.equal(runs.getRun(handle.id).status, "spawning");
const second = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "me too" }); // Start a run for the lane
assert.equal(second.status, 409); const restorePath = stubClaudeBinary("start-twice");
assert.equal(second.body.error.code, "ERUNLIVE"); const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
// The first run is still the recorded one — nothing was overwritten. assert.equal(started.status, 200);
const after = await request("GET", `/api/lanes/${lane.id}`); const runId = started.body.lane.run_id;
assert.equal(after.body.lane.run_id, handle.id); assert.equal(runs.getRun(runId).status, "running");
// A start IS allowed again once that run is genuinely finished. // Mock tmux so the session appears live
await request("POST", `/api/lanes/${lane.id}/stop`); tmux.__setExecImpl(async (cmd, args) => {
const deadline = Date.now() + 2000; if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
while (runs.getRun(handle.id).status === "spawning" && Date.now() < deadline) { return { code: 0, stdout: "", stderr: "" };
await new Promise((resolve) => setTimeout(resolve, 10)); }
if (cmd === "tmux" && args[0] === "list-sessions") {
return { code: 0, stdout: "", stderr: "" };
}
return { code: 1, stdout: "", stderr: "" };
});
try {
// Try to start a second run — should be refused
const second = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "me too" });
assert.equal(second.status, 409);
assert.equal(second.body.error.code, "ERUNLIVE");
// The first run is still the recorded one — nothing was overwritten.
const after = await request("GET", `/api/lanes/${lane.id}`);
assert.equal(after.body.lane.run_id, runId);
} finally {
tmux.__reset();
// The real tmux session behind the "first" run is never reset/killed
// in this test, mocked or otherwise — kill it for real so it doesn't
// leak past this test run.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
}); });
@@ -973,10 +1018,10 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(typeof runId, "string"); assert.equal(typeof runId, "string");
runs.killRun(runId); runs.killRun(runId);
const deadline = Date.now() + 2000; const deadline = Date.now() + 2000;
while (!runs.getRun(runId).actualExitedAt && Date.now() < deadline) { while (runs.getRun(runId).status !== "gone" && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
} }
assert.notEqual(runs.getRun(runId).actualExitedAt, null); assert.equal(runs.getRun(runId).status, "gone");
} finally { } finally {
process.env.PATH = originalPath; process.env.PATH = originalPath;
} }
@@ -1055,40 +1100,6 @@ describe("destructive lane lifecycle actions", () => {
describe("lane ensure, start mode, lane_id and releasing a finished run", () => { describe("lane ensure, start mode, lane_id and releasing a finished run", () => {
const { db } = require("../db"); const { db } = require("../db");
/**
* Put a throwaway `claude` on PATH for the duration of one test. The script
* records its argv so a test can prove what the real spawn received.
*/
function withFakeClaude(name, scriptBody, fn) {
const bin = path.join(ROOT, `fake-claude-${name}`);
fs.mkdirSync(bin, { recursive: true });
const argvLog = path.join(bin, "argv.json");
fs.writeFileSync(
path.join(bin, "claude"),
"#!/usr/bin/env node\n" +
`require("node:fs").writeFileSync(${JSON.stringify(argvLog)}, JSON.stringify(process.argv.slice(2)));\n` +
scriptBody
);
fs.chmodSync(path.join(bin, "claude"), 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return Promise.resolve(fn({ argvLog })).finally(() => {
process.env.PATH = originalPath;
});
}
/** Poll until the lane no longer holds a run, then return it. */
async function waitForRelease(id) {
const deadline = Date.now() + 7000;
let lane;
while (Date.now() < deadline) {
lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
if (lane.run_id === null) return lane;
await new Promise((resolve) => setTimeout(resolve, 25));
}
assert.fail(`lane ${id} still held run_id ${lane && lane.run_id} after 7 seconds`);
}
async function adoptedLane(name) { async function adoptedLane(name) {
const cwd = path.join(ROOT, `ensure-${name}`); const cwd = path.join(ROOT, `ensure-${name}`);
fs.mkdirSync(cwd, { recursive: true }); fs.mkdirSync(cwd, { recursive: true });
@@ -1161,152 +1172,90 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM lanes WHERE cwd = ?").get(cwd).count, 0); assert.equal(db.prepare("SELECT COUNT(*) AS count FROM lanes WHERE cwd = ?").get(cwd).count, 0);
}); });
it("rejects an unknown start mode with 400 and spawns nothing", async () => { it("leaves a lane that already has a live run_id untouched during healing", async () => {
const lane = await adoptedLane("bad-mode"); const tmux = require("../lib/tmux");
const r = await request("POST", `/api/lanes/${lane.id}/start`, {
prompt: "hi",
mode: "telepathy",
});
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADMODE");
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).body.lane.run_id, null);
});
it("passes mode headless through to the spawn and records lane_id in dashboard_runs", async () => {
const lane = await adoptedLane("headless-mode");
await withFakeClaude("headless", "process.exit(0);\n", async ({ argvLog }) => {
const started = await request("POST", `/api/lanes/${lane.id}/start`, {
prompt: "one shot",
mode: "headless",
});
assert.equal(started.status, 200, JSON.stringify(started.body));
const runId = started.body.lane.run_id;
assert.equal(typeof runId, "string");
const row = db.prepare("SELECT mode, lane_id FROM dashboard_runs WHERE id = ?").get(runId);
assert.equal(row.mode, "headless");
assert.equal(row.lane_id, lane.id);
await waitForRelease(lane.id);
// The real child saw the headless argv shape: the prompt in argv via -p.
const argv = JSON.parse(fs.readFileSync(argvLog, "utf8"));
assert.equal(argv.includes("-p"), true);
assert.equal(argv[argv.indexOf("-p") + 1], "one shot");
});
});
it("filters GET /api/run/history by laneId and leaves non-lane runs unlabelled", async () => {
const lane = await adoptedLane("history-filter");
const other = await adoptedLane("history-filter-other");
let laneRunId;
let plainRunId;
await withFakeClaude("history", "process.exit(0);\n", async () => {
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "lane run" });
laneRunId = started.body.lane.run_id;
await waitForRelease(lane.id);
const plain = await request("POST", "/api/run", {
prompt: "plain run",
mode: "headless",
cwd: ROOT,
});
assert.equal(plain.status, 201);
plainRunId = plain.body.id;
});
const filtered = await request("GET", `/api/run/history?laneId=${lane.id}`);
assert.equal(filtered.status, 200);
assert.deepEqual(
filtered.body.items.map((it) => it.id),
[laneRunId]
);
assert.equal(filtered.body.items[0].lane_id, lane.id);
const empty = await request("GET", `/api/run/history?laneId=${other.id}`);
assert.deepEqual(empty.body.items, []);
// POST /api/run is unchanged: its row carries no lane.
const all = await request("GET", "/api/run/history?limit=500");
const plainRow = all.body.items.find((it) => it.id === plainRunId);
assert.equal(plainRow.lane_id, null);
});
it("releases the lane when the run exits on its own", async () => {
const lane = await adoptedLane("release-exit-zero");
await withFakeClaude("exit-zero", "process.exit(0);\n", async () => {
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "quick" });
assert.equal(started.status, 200, JSON.stringify(started.body));
assert.equal(started.body.lane.status, "running");
const released = await waitForRelease(lane.id);
assert.equal(released.run_id, null);
assert.equal(released.status, "idle");
assert.equal(runs.getRun(started.body.lane.run_id).status, "completed");
});
});
it("releases the lane when the run exits non-zero", async () => {
const lane = await adoptedLane("release-exit-three");
await withFakeClaude("exit-three", "process.exit(3);\n", async () => {
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "fails" });
assert.equal(started.status, 200, JSON.stringify(started.body));
const released = await waitForRelease(lane.id);
assert.equal(released.run_id, null);
assert.equal(released.status, "idle");
assert.equal(runs.getRun(started.body.lane.run_id).status, "error");
});
});
it("releases the lane when the child never spawns at all", async () => {
const lane = await adoptedLane("release-spawn-error");
const emptyBin = path.join(ROOT, "release-empty-bin");
fs.mkdirSync(emptyBin, { recursive: true });
const originalPath = process.env.PATH;
process.env.PATH = emptyBin;
try {
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "no binary" });
assert.equal(started.status, 200, JSON.stringify(started.body));
const released = await waitForRelease(lane.id);
assert.equal(released.run_id, null);
assert.equal(released.status, "idle");
assert.equal(runs.getRun(started.body.lane.run_id).status, "error");
} finally {
process.env.PATH = originalPath;
}
});
it("releases the lane when a live run is killed", async () => {
const lane = await adoptedLane("release-killed");
await withFakeClaude(
"killed",
"process.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n",
async () => {
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "long" });
assert.equal(started.status, 200, JSON.stringify(started.body));
const runId = started.body.lane.run_id;
assert.equal(runs.killRun(runId), true);
const released = await waitForRelease(lane.id);
assert.equal(released.run_id, null);
assert.equal(released.status, "idle");
assert.equal(runs.getRun(runId).status, "killed");
}
);
});
it("leaves a lane that already moved on to a different run alone", async () => {
const lane = await adoptedLane("release-moved-on"); const lane = await adoptedLane("release-moved-on");
const child = makeRunChild({ exitsOnKill: true });
const stale = runs.__injectChildForTest({ child });
const live = runs.__injectChildForTest({ child: makeRunChild({ exitsOnKill: false }) });
await request("PATCH", `/api/lanes/${lane.id}`, { run_id: live.id, status: "running" });
// The stale run's exit must not clear the lane's CURRENT run. // Create a run for this lane.
runs.killRun(stale.id); const restorePath = stubClaudeBinary("release-moved-on");
const deadline = Date.now() + 2000; const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
while (!runs.getRun(stale.id).actualExitedAt && Date.now() < deadline) { assert.equal(started.status, 200, JSON.stringify(started.body));
await new Promise((resolve) => setTimeout(resolve, 10)); const runId = started.body.lane.run_id;
// Mock tmux so the run appears to be live.
tmux.__setExecImpl(async (cmd, args) => {
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
return { code: 0, stdout: "", stderr: "" };
}
if (cmd === "tmux" && args[0] === "list-sessions") {
return { code: 0, stdout: "", stderr: "" };
}
return { code: 1, stdout: "", stderr: "" };
});
try {
// Read the lane — it should NOT clear the run_id since it's still live.
const before = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
assert.equal(before.run_id, runId);
assert.equal(before.status, "running");
// Read again — same result, healing preserves live runs.
const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
assert.equal(after.run_id, runId);
assert.equal(after.status, "running");
} finally {
tmux.__reset();
// The app never calls kill-session here (healing preserves the "live"
// run) — kill the real tmux session directly so it doesn't leak.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
}
});
it("clears a stale run_id and sets status to idle when the tmux session is gone", async () => {
const tmux = require("../lib/tmux");
const lane = await adoptedLane("release-stale-run");
// Start a run for this lane.
const restorePath = stubClaudeBinary("release-stale-run");
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
assert.equal(started.status, 200, JSON.stringify(started.body));
const runId = started.body.lane.run_id;
assert.equal(typeof runId, "string");
assert.equal(started.body.lane.status, "running");
// Mock tmux so the session appears to be gone (has-session fails with status 1).
tmux.__setExecImpl((args) => {
if (args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
const e = new Error("no such session");
e.status = 1;
throw e;
}
if (args[0] === "list-sessions") {
return "";
}
return "";
});
try {
// Read the lane — it should clear the run_id and set status to idle.
const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
assert.equal(after.run_id, null, "run_id should be cleared for stale session");
assert.equal(after.status, "idle", "status should be idle after run is gone");
} finally {
tmux.__reset();
// The app believes the session is already gone and never calls
// kill-session — kill the real tmux session directly so it doesn't leak.
try {
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
} catch {
// already gone
}
restorePath();
} }
assert.notEqual(runs.getRun(stale.id).actualExitedAt, null);
const after = (await request("GET", `/api/lanes/${lane.id}`)).body.lane;
assert.equal(after.run_id, live.id);
assert.equal(after.status, "running");
}); });
}); });
+7
View File
@@ -79,6 +79,13 @@ describe("syncMcp — reading and relocating", () => {
`${lane.cwd}/.playwright-mcp/profiles/default` `${lane.cwd}/.playwright-mcp/profiles/default`
); );
}); });
it("falls back to the lane's own cwd when source_repo is null (adopted lane)", async () => {
const lane = makeLane(null);
writeClaudeJson({ [lane.cwd]: { mcpServers: { playwright: { command: "npx", args: [] } } } });
const result = await laneMcp.syncMcp(lane);
assert.deepEqual(result.servers, ["playwright"]);
});
}); });
describe("syncMcp — Playwright output-dir pinning", () => { describe("syncMcp — Playwright output-dir pinning", () => {
+3 -5
View File
@@ -645,14 +645,12 @@ describe("lane actions", () => {
await request("DELETE", `/api/lanes/${c.body.lane.id}`); await request("DELETE", `/api/lanes/${c.body.lane.id}`);
}); });
it("message on a lane with a recorded-but-not-live run returns 409", async () => { it("message on a lane is no longer supported via REST", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" }); const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" });
const id = c.body.lane.id; const id = c.body.lane.id;
// Patch the lane with a bogus run_id (never existed, so not live).
await request("PATCH", `/api/lanes/${id}`, { run_id: "nonexistent-run" });
const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" }); const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" });
assert.equal(r.status, 409); assert.equal(r.status, 400);
assert.equal(r.body.error.code, "ENORUN"); assert.equal(r.body.error.code, "EUNSUPPORTED");
await request("DELETE", `/api/lanes/${id}`); await request("DELETE", `/api/lanes/${id}`);
}); });
}); });
+17 -1
View File
@@ -45,9 +45,25 @@ const CLI = path.join(__dirname, "..", "..", "bin", "ccam.js");
let server; let server;
let BASE; let BASE;
// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
// very suite) sets in its own environment — those leak to every child
// process and override an explicit `cwd`, so without this a git command
// meant for this test's throwaway tmp repo silently operates on the real
// repo running the hook instead.
const GIT_ENV = { ...process.env };
delete GIT_ENV.GIT_DIR;
delete GIT_ENV.GIT_WORK_TREE;
delete GIT_ENV.GIT_INDEX_FILE;
delete GIT_ENV.GIT_COMMON_DIR;
delete GIT_ENV.GIT_OBJECT_DIRECTORY;
delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete GIT_ENV.GIT_PREFIX;
delete GIT_ENV.GIT_NAMESPACE;
delete GIT_ENV.GIT_CONFIG_PARAMETERS;
function git(args, cwd) { function git(args, cwd) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn("git", args, { cwd }); const child = spawn("git", args, { cwd, env: GIT_ENV });
let stderr = ""; let stderr = "";
child.stderr.on("data", (chunk) => (stderr += chunk)); child.stderr.on("data", (chunk) => (stderr += chunk));
child.on("error", reject); child.on("error", reject);
+116
View File
@@ -0,0 +1,116 @@
/**
* @file pty-attach.test.js
* @description Unit tests for the PTY-attach helper's runId validation and
* data/control framing, using a fake node-pty implementation (no real tmux
* or PTY spawned).
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const ptyAttach = require("../lib/pty-attach");
function makeFakePty() {
const emitter = new EventEmitter();
const writes = [];
const pty = {
onData: (fn) => emitter.on("data", fn),
onExit: (fn) => emitter.on("exit", fn),
write: (d) => writes.push(d),
resize: (cols, rows) => writes.push({ resize: [cols, rows] }),
kill: () => emitter.emit("exit", { exitCode: 0 }),
__emitter: emitter,
__writes: writes,
};
return pty;
}
function makeFakeWs() {
const emitter = new EventEmitter();
const sent = [];
return {
on: (evt, fn) => emitter.on(evt, fn),
send: (data, opts) => sent.push({ data, binary: !!(opts && opts.binary) }),
__emitter: emitter,
__sent: sent,
};
}
describe("pty-attach", () => {
let fakePty;
beforeEach(() => {
fakePty = makeFakePty();
ptyAttach.__setSpawnImpl(() => fakePty);
});
it("rejects a runId that doesn't match ccam-lane-<digits>", () => {
assert.throws(() => ptyAttach.validateRunId("../../etc/passwd"), /EBADRUNID/);
assert.throws(() => ptyAttach.validateRunId("some-other-session"), /EBADRUNID/);
});
it("accepts a well-formed runId", () => {
assert.doesNotThrow(() => ptyAttach.validateRunId("ccam-lane-42"));
});
it("wires PTY data to binary WS frames and WS binary frames to PTY writes", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
fakePty.__emitter.emit("data", "hello from claude");
assert.equal(ws.__sent.length, 1);
assert.equal(ws.__sent[0].data, "hello from claude");
assert.equal(ws.__sent[0].binary, true);
ws.__emitter.emit("message", Buffer.from("typed text"), { binary: true });
assert.deepEqual(fakePty.__writes[0], "typed text");
});
it("routes a JSON text frame with type resize to pty.resize", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
ws.__emitter.emit(
"message",
Buffer.from(JSON.stringify({ type: "resize", cols: 100, rows: 40 })),
{
binary: false,
}
);
assert.deepEqual(fakePty.__writes[0], { resize: [100, 40] });
});
it("forwards a plain text WS frame (keystrokes) to pty.write", () => {
// xterm.js's onData hands the browser a plain string, and
// WebSocket.send(string) always emits a TEXT frame — so every keystroke
// arrives here as non-binary. This must reach the pty, not be dropped as
// an unparseable control message.
const ws = makeFakeWs();
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
ws.__emitter.emit("message", Buffer.from("ls -la\r"), { binary: false });
assert.deepEqual(fakePty.__writes[0], "ls -la\r");
});
it("sends an exit control message and closes on PTY exit", () => {
const ws = makeFakeWs();
let closed = false;
ws.close = () => {
closed = true;
};
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
fakePty.__emitter.emit("exit", { exitCode: 0 });
const last = ws.__sent[ws.__sent.length - 1];
assert.equal(last.binary, false);
assert.deepEqual(JSON.parse(last.data), { type: "exit", code: 0 });
assert.equal(closed, true);
});
it("kills the PTY attach process when the WS connection closes", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "ccam-lane-1", { cols: 80, rows: 24 });
let killed = false;
fakePty.kill = () => {
killed = true;
};
ws.__emitter.emit("close");
assert.equal(killed, true);
});
});
+166
View File
@@ -0,0 +1,166 @@
/**
* @file pty-run.test.js
* @description Unit tests for the tmux-backed run lifecycle. Injects a fake
* tmux exec implementation (via tmux.js's test seam) so no real tmux binary
* is invoked.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pty-run-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
describe("pty-run", () => {
after(() => {
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort */
}
});
beforeEach(() => {
tmux.__reset();
});
it("spawnRun creates a new tmux session named ccam-lane-<id> when none exists", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "has-session") {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
const handle = pty.spawnRun({ laneId: 42, cwd: "/tmp/repo", model: "opus" });
assert.equal(handle.id, "ccam-lane-42");
const newSessionCall = calls.find((c) => c[0] === "new-session");
assert.ok(newSessionCall, "expected a new-session call");
assert.deepEqual(newSessionCall.slice(0, 6), [
"new-session",
"-d",
"-s",
"ccam-lane-42",
"-c",
"/tmp/repo",
]);
assert.ok(newSessionCall.includes("claude"));
assert.ok(newSessionCall.includes("--model"));
assert.ok(newSessionCall.includes("opus"));
});
it("spawnRun is a no-op (adopts) when the tmux session already exists", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return ""; // has-session succeeds → already running
});
const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" });
assert.equal(handle.id, "ccam-lane-7");
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
});
it("spawnRun with resumeSessionId passes --resume in argv", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 1, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
assert.ok(newSessionArgv.includes("--resume"));
assert.ok(newSessionArgv.includes("abc12345"));
});
it("spawnRun appends a positional initial prompt after argv flags", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 3, cwd: "/tmp/repo", initialPrompt: "fix the bug" });
assert.equal(newSessionArgv[newSessionArgv.length - 1], "fix the bug");
});
it("killRun calls tmux kill-session with the run id", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
assert.equal(pty.killRun("ccam-lane-5"), true);
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "ccam-lane-5"));
});
it("listRuns reflects live tmux-session state, not cached memory", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "list-sessions") return "ccam-lane-1\nccam-lane-2\n";
return "";
});
const first = pty.listRuns();
assert.deepEqual(first.map((r) => r.id).sort(), ["ccam-lane-1", "ccam-lane-2"]);
// Session killed out-of-band (not through killRun) — next list() call
// must self-correct, proving state is computed, not stored.
tmux.__setExecImpl((args) => {
if (args[0] === "list-sessions") return "ccam-lane-1\n";
return "";
});
const second = pty.listRuns();
assert.deepEqual(
second.map((r) => r.id),
["ccam-lane-1"]
);
});
it("laneIdFromRunId parses the numeric lane id back out", () => {
assert.equal(pty.laneIdFromRunId("ccam-lane-42"), 42);
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
});
it("getRun returns the recorded prompt for a live run", () => {
let sessionExists = false;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
if (sessionExists) {
return ""; // session exists
}
// Session doesn't exist yet
const e = new Error("no such session");
e.status = 1;
throw e;
}
if (args[0] === "new-session") {
sessionExists = true; // Mark session as created
}
return "";
});
const handle = pty.spawnRun({
laneId: 99,
cwd: "/tmp/test",
initialPrompt: "the live prompt",
});
const retrieved = pty.getRun(handle.id);
assert.equal(retrieved.id, "ccam-lane-99");
assert.equal(retrieved.promptPreview, "the live prompt");
assert.equal(retrieved.status, "running");
});
});
+74 -440
View File
@@ -1,28 +1,22 @@
// server/__tests__/run.test.js
/** /**
* @file run.test.js * @file run.test.js
* @description Tests for the Run feature: spawner injection, route * @description Route tests for the terminal-run feature: same-origin guard,
* validation, same-origin guard, cwd suggestions, resume validation, * laneId/cwd validation, spawn/kill/list against a mocked tmux backend.
* envelope storage / attach, and end-to-end handle lifecycle. Uses a fake
* child (PassThrough streams + EventEmitter) so we never invoke the real
* `claude` binary.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/ */
const { describe, it, before, after, beforeEach } = require("node:test"); const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const path = require("node:path"); const path = require("node:path");
const fs = require("node:fs"); const fs = require("node:fs");
const os = require("node:os"); const os = require("node:os");
const http = require("node:http"); const http = require("node:http");
const { PassThrough } = require("node:stream");
const { EventEmitter } = require("node:events");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-test-")); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-route-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { createApp } = require("../index"); const { createApp } = require("../index");
const runs = require("../lib/run-spawner"); const tmux = require("../lib/tmux");
const runRoute = require("../routes/run");
let server; let server;
let BASE; let BASE;
@@ -66,44 +60,25 @@ function fetchJson(p, opts = {}) {
}); });
} }
function makeFakeChild() {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = new PassThrough();
child.killed = false;
child.kill = function (sig) {
this.killed = true;
setImmediate(() => this.emit("exit", sig === "SIGTERM" ? 143 : 0, sig || null));
};
return child;
}
describe("/api/run", () => { describe("/api/run", () => {
before(async () => { before(async () => {
const app = createApp(); const app = createApp();
server = http.createServer(app); server = http.createServer(app);
await new Promise((r) => server.listen(0, r)); await new Promise((r) => server.listen(0, r));
const port = server.address().port; BASE = `http://127.0.0.1:${server.address().port}`;
BASE = `http://127.0.0.1:${port}`;
}); });
after(async () => { after(async () => {
await new Promise((r) => server.close(r)); await new Promise((r) => server.close(r));
// The SQLite DB lives under TMP and better-sqlite3 holds it open, so on
// Windows rmSync hits EPERM (can't remove a dir with an open handle).
// maxRetries covers transient locks; the try/catch makes the rest
// best-effort — a leftover temp dir must not fail the suite (the OS
// reclaims os.tmpdir()).
try { try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch { } catch {
/* best-effort temp cleanup */ /* best-effort */
} }
}); });
beforeEach(() => { beforeEach(() => {
runs.__reset(); tmux.__reset();
}); });
it("rejects cross-origin browser requests", async () => { it("rejects cross-origin browser requests", async () => {
@@ -115,429 +90,88 @@ describe("/api/run", () => {
}); });
it("allows requests with no Origin (CLI/curl)", async () => { it("allows requests with no Origin (CLI/curl)", async () => {
tmux.__setExecImpl((args) => {
if (args[0] === "list-sessions") {
const e = new Error("no server running");
e.status = 1;
throw e;
}
return "";
});
const { status, body } = await fetchJson("/api/run"); const { status, body } = await fetchJson("/api/run");
assert.equal(status, 200); assert.equal(status, 200);
assert.ok(Array.isArray(body.items)); assert.deepEqual(body.items, []);
}); });
it("allows localhost Origin", async () => { it("POST / rejects a missing laneId", async () => {
const { status } = await fetchJson("/api/run", { const { status, body } = await fetchJson("/api/run", { method: "POST", body: { cwd: TMP } });
headers: { Origin: "http://localhost:5173" }, assert.equal(status, 400);
assert.equal(body.error.code, "EBADLANE");
});
it("POST / rejects a non-existent cwd", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { laneId: 1, cwd: "/definitely/not/a/real/path" },
}); });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("POST / spawns a tmux session and GET /:id finds it", async () => {
let hasSessionCalls = 0;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
hasSessionCalls++;
// First call (inside spawnRun): not yet running. Every call after
// (GET /:id) sees it as running.
if (hasSessionCalls === 1) {
const e = new Error("gone");
e.status = 1;
throw e;
}
return "";
}
return "";
});
const spawned = await fetchJson("/api/run", { method: "POST", body: { laneId: 9, cwd: TMP } });
assert.equal(spawned.status, 201);
assert.equal(spawned.body.id, "ccam-lane-9");
const fetched = await fetchJson(`/api/run/${spawned.body.id}`);
assert.equal(fetched.status, 200);
assert.equal(fetched.body.status, "running");
});
it("DELETE /:id kills a live tmux session", async () => {
tmux.__setExecImpl(() => ""); // has-session succeeds; kill-session succeeds
const { status, body } = await fetchJson("/api/run/ccam-lane-9", { method: "DELETE" });
assert.equal(status, 200); assert.equal(status, 200);
assert.deepEqual(body, { ok: true });
}); });
it("POST / requires prompt", async () => { it("DELETE /:id returns 404 for a session that doesn't exist", async () => {
const { status, body } = await fetchJson("/api/run", { method: "POST", body: {} }); tmux.__setExecImpl((args) => {
assert.equal(status, 400); if (args[0] === "has-session") {
assert.equal(body.error.code, "EBADPROMPT"); const e = new Error("gone");
}); e.status = 1;
throw e;
it("POST / rejects non-existent cwd", async () => { }
const { status, body } = await fetchJson("/api/run", { return "";
method: "POST",
body: { prompt: "hi", mode: "headless", cwd: "/nope/does/not/exist" },
}); });
assert.equal(status, 400); const { status } = await fetchJson("/api/run/ccam-lane-999", { method: "DELETE" });
assert.equal(body.error.code, "EBADCWD");
});
it("POST / rejects relative cwd", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "headless", cwd: "./relative" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("GET /:id returns 404 for unknown id", async () => {
const { status, body } = await fetchJson("/api/run/does-not-exist");
assert.equal(status, 404);
assert.equal(body.error.code, "ENOTFOUND");
});
it("DELETE /:id returns 404 for unknown id", async () => {
const { status } = await fetchJson("/api/run/does-not-exist", { method: "DELETE" });
assert.equal(status, 404); assert.equal(status, 404);
}); });
it("POST /:id/message rejects empty text", async () => { it("GET /tmux reports availability from the tmux wrapper", async () => {
const { status, body } = await fetchJson("/api/run/x/message", { tmux.__setExecImpl(() => "tmux 3.4");
method: "POST", const { body } = await fetchJson("/api/run/tmux");
body: {}, assert.equal(body.available, true);
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADINPUT");
}); });
// ── /api/run/cwds suggestions ───────────────────────────────────── it("GET /cwds still returns suggested directories (unchanged behavior)", async () => {
it("GET /cwds returns dashboard + home suggestions with absolute paths", async () => {
const { status, body } = await fetchJson("/api/run/cwds"); const { status, body } = await fetchJson("/api/run/cwds");
assert.equal(status, 200); assert.equal(status, 200);
assert.ok(Array.isArray(body.items)); assert.ok(Array.isArray(body.items));
const kinds = body.items.map((i) => i.kind);
assert.ok(kinds.includes("dashboard"), "dashboard cwd present");
assert.ok(kinds.includes("home"), "home present");
for (const it of body.items) {
assert.equal(typeof it.path, "string");
// path.isAbsolute is platform-aware: "/x" on POSIX, "C:\\x" on Windows.
assert.ok(path.isAbsolute(it.path), "absolute path");
assert.equal(typeof it.label, "string");
}
});
// ── /api/run/binary probe ─────────────────────────────────────────
it("GET /binary returns shape { found, path }", async () => {
const { status, body } = await fetchJson("/api/run/binary");
assert.equal(status, 200);
assert.equal(typeof body.found, "boolean");
if (body.found) assert.equal(typeof body.path, "string");
});
// ── Resume validation ─────────────────────────────────────────────
it("POST / rejects bad resumeSessionId format", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "conversation", resumeSessionId: "x" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADSESSION");
});
it("POST / rejects unknown effort level", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: { prompt: "hi", mode: "conversation", effort: "ludicrous" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADEFFORT");
});
it("POST / rejects resumeSessionId with headless mode", async () => {
const { status, body } = await fetchJson("/api/run", {
method: "POST",
body: {
prompt: "hi",
mode: "headless",
resumeSessionId: "deadbeef-cafe-1234-5678-feedfacefeed",
},
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADMODE");
});
// ── HTTP GET /:id?envelopes=1 (attach payload) ────────────────────
it("GET /:id?envelopes=1 returns the in-memory envelope log", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"sX"}\n`);
await new Promise((r) => setImmediate(r));
const { status, body } = await fetchJson(`/api/run/${handle.id}?envelopes=1`);
assert.equal(status, 200);
assert.ok(Array.isArray(body.envelopes));
assert.equal(body.envelopes.length, 1);
assert.equal(body.envelopes[0].type, "system");
});
it("GET /files returns paths matching q, skipping node_modules", async () => {
// Build a tiny fixture under tmp so the test is hermetic.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "run-files-"));
fs.mkdirSync(path.join(tmp, "src"));
fs.mkdirSync(path.join(tmp, "node_modules", "leftover-pkg"), { recursive: true });
fs.writeFileSync(path.join(tmp, "README.md"), "x");
fs.writeFileSync(path.join(tmp, "src", "index.ts"), "x");
fs.writeFileSync(path.join(tmp, "node_modules", "leftover-pkg", "x.js"), "x");
try {
const { status, body } = await fetchJson(
`/api/run/files?cwd=${encodeURIComponent(tmp)}&q=index`
);
assert.equal(status, 200);
assert.deepEqual(body.items.sort(), ["src/index.ts"]);
// No q → returns top-level files (excluding node_modules)
const all = await fetchJson(`/api/run/files?cwd=${encodeURIComponent(tmp)}`);
assert.ok(all.body.items.includes("README.md"));
assert.ok(!all.body.items.some((p) => p.startsWith("node_modules")));
} finally {
try {
fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup (Windows may hold a handle) */
}
}
});
it("GET /files rejects missing/invalid cwd", async () => {
const { status, body } = await fetchJson("/api/run/files?cwd=/does/not/exist");
assert.equal(status, 400);
assert.equal(body.error.code, "EBADCWD");
});
it("GET /:id without ?envelopes returns metadata only", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
const { body } = await fetchJson(`/api/run/${handle.id}`);
assert.equal(body.envelopes, undefined);
assert.equal(body.envelopeCount, 1);
});
});
describe("run-spawner unit", () => {
beforeEach(() => {
runs.__reset();
});
it("injected child parses stream-json envelopes and broadcasts", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(
`{"type":"system","subtype":"init","session_id":"sess-abc","model":"opus"}\n`
);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n`);
// Allow the line parser to flush
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "running");
assert.equal(live.sessionId, "sess-abc");
assert.equal(live.envelopeCount, 2);
});
it("sendInput writes a stream-json envelope to stdin", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
// Force into running state via a parsed envelope first
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"s1"}\n`);
await new Promise((r) => setImmediate(r));
const chunks = [];
fake.stdin.on("data", (c) => chunks.push(c.toString()));
runs.sendInput(handle.id, "follow-up");
await new Promise((r) => setImmediate(r));
const written = chunks.join("");
const lines = written.trim().split("\n");
const obj = JSON.parse(lines[lines.length - 1]);
assert.equal(obj.type, "user");
assert.equal(obj.message.content, "follow-up");
});
it("sendInput rejects on headless handles", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "headless" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, "x"), /only conversation mode/);
});
it("kill marks handle as killed and emits exit", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
runs.killRun(handle.id);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "killed");
});
it("escalates to SIGKILL when SIGTERM was delivered but the child has not exited", async () => {
const fake = makeFakeChild();
const signals = [];
fake.kill = function (signal) {
this.killed = true;
signals.push(signal);
if (signal === "SIGKILL") setImmediate(() => this.emit("exit", 137, signal));
return true;
};
const handle = runs.__injectChildForTest({ child: fake });
const originalSetTimeout = global.setTimeout;
global.setTimeout = (callback, delay, ...args) => {
if (delay === 5000) {
callback(...args);
return { unref: () => {} };
}
return originalSetTimeout(callback, delay, ...args);
};
try {
runs.killRun(handle.id);
} finally {
global.setTimeout = originalSetTimeout;
}
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
assert.notEqual(runs.getRun(handle.id).actualExitedAt, null);
});
it("exit with code 0 marks completed", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "completed");
assert.equal(live.exitCode, 0);
});
it("exit with non-zero code marks error", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 1, null);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.status, "error");
});
it("malformed JSON lines do not crash; go to stderr buffer", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.stdout.write("not valid json\n");
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.match(live.stderrTail, /parse-error/);
});
it("listRuns returns handles sorted newest first", async () => {
const a = runs.__injectChildForTest({ child: makeFakeChild() });
await new Promise((r) => setTimeout(r, 5));
const b = runs.__injectChildForTest({ child: makeFakeChild() });
const list = runs.listRuns();
assert.equal(list[0].id, b.id);
assert.equal(list[1].id, a.id);
});
});
describe("sameOriginGuard helper", () => {
it("loopback Origin passes", () => {
const next = () => "OK";
const res = {};
const result = runRoute.__sameOriginGuard(
{ headers: { origin: "http://127.0.0.1:4820" } },
res,
next
);
assert.equal(result, "OK");
});
it("missing Origin passes (CLI use case)", () => {
const next = () => "OK";
const result = runRoute.__sameOriginGuard({ headers: {} }, {}, next);
assert.equal(result, "OK");
});
it("non-loopback Origin is blocked", () => {
let captured = null;
const res = {
status(code) {
captured = { code };
return this;
},
json(body) {
captured.body = body;
return this;
},
};
runRoute.__sameOriginGuard({ headers: { origin: "http://attacker.com" } }, res, () => {});
assert.equal(captured.code, 403);
assert.equal(captured.body.error.code, "EBADORIGIN");
});
});
describe("run-spawner extras", () => {
beforeEach(() => {
runs.__reset();
});
it("getRun (no opts) returns metadata only — no envelopes field", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init","session_id":"s1"}\n`);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}\n`);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id);
assert.equal(live.envelopeCount, 2);
assert.equal(live.envelopes, undefined);
});
it("getRun({includeEnvelopes:true}) returns the in-memory log", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
fake.stdout.write(`{"type":"assistant","message":{"content":[{"type":"text","text":"x"}]}}\n`);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id, { includeEnvelopes: true });
assert.ok(Array.isArray(live.envelopes));
assert.equal(live.envelopes.length, 2);
assert.equal(live.envelopes[0].type, "system");
});
it("listRuns surfaces resumeSessionId (null for fresh)", async () => {
runs.__injectChildForTest({ child: makeFakeChild(), mode: "conversation" });
const list = runs.listRuns();
assert.equal(list.length, 1);
assert.equal(list[0].resumeSessionId, null);
});
it("killRun is idempotent on already-completed handles", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
assert.equal(runs.getRun(handle.id).status, "completed");
// Second kill on a completed handle should be a safe no-op (returns true).
assert.equal(runs.killRun(handle.id), true);
assert.equal(runs.getRun(handle.id).status, "completed");
});
it("killRun returns false for an unknown id", () => {
assert.equal(runs.killRun("does-not-exist"), false);
});
it("sendInput throws ENOTFOUND for unknown id", () => {
assert.throws(() => runs.sendInput("nope", "hi"), /not found/);
});
it("sendInput throws ENOTRUNNING when handle has already exited", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.emit("exit", 0, null);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, "x"), /run is (completed|killed|error)/);
});
it("sendInput rejects empty text", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
fake.stdout.write(`{"type":"system","subtype":"init"}\n`);
await new Promise((r) => setImmediate(r));
assert.throws(() => runs.sendInput(handle.id, ""), /text is required/);
});
it("envelope log is capped at 500 entries", async () => {
const fake = makeFakeChild();
const handle = runs.__injectChildForTest({ child: fake, mode: "conversation" });
let line = "";
for (let i = 0; i < 600; i++) line += `{"type":"assistant","i":${i}}\n`;
fake.stdout.write(line);
await new Promise((r) => setImmediate(r));
const live = runs.getRun(handle.id, { includeEnvelopes: true });
assert.equal(live.envelopeCount, 600);
assert.equal(live.envelopes.length, 500);
// The cap drops the OLDEST entries — last entry should be the latest.
assert.equal(live.envelopes[live.envelopes.length - 1].i, 599);
});
it("getMaxConcurrent respects RUN_MAX_CONCURRENT env override", () => {
const orig = process.env.RUN_MAX_CONCURRENT;
try {
process.env.RUN_MAX_CONCURRENT = "7";
assert.equal(runs.getMaxConcurrent(), 7);
process.env.RUN_MAX_CONCURRENT = "garbage";
assert.ok(runs.getMaxConcurrent() >= 1, "falls back to default on non-numeric");
delete process.env.RUN_MAX_CONCURRENT;
assert.ok(runs.getMaxConcurrent() >= 1);
} finally {
if (orig != null) process.env.RUN_MAX_CONCURRENT = orig;
else delete process.env.RUN_MAX_CONCURRENT;
}
}); });
}); });
-141
View File
@@ -1,141 +0,0 @@
/**
* @file stream-json-parser.test.js
* @description Unit tests for the newline-delimited JSON line buffer used to
* parse `claude --output-format stream-json` output. Verifies chunked input,
* partial lines spanning chunks, malformed lines, empty input, multiple
* objects per chunk, and flush semantics.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { createLineParser } = require("../lib/stream-json-parser");
function collect() {
const objects = [];
const errors = [];
const parser = createLineParser(
(obj) => objects.push(obj),
(err, raw) => errors.push({ message: err.message, raw })
);
return { parser, objects, errors };
}
describe("stream-json-parser", () => {
it("parses a single complete line", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"system","subtype":"init"}\n');
assert.equal(errors.length, 0);
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "system");
});
it("parses multiple lines in one chunk", () => {
const { parser, objects } = collect();
parser.push('{"type":"a"}\n{"type":"b"}\n{"type":"c"}\n');
assert.deepEqual(
objects.map((o) => o.type),
["a", "b", "c"]
);
});
it("reassembles a JSON object split across two chunks", () => {
const { parser, objects } = collect();
parser.push('{"type":"split","val":');
parser.push('"hello"}\n');
assert.equal(objects.length, 1);
assert.equal(objects[0].val, "hello");
});
it("reassembles a JSON object split across many small chunks", () => {
const { parser, objects } = collect();
const full = '{"type":"chunky","payload":{"deep":{"nested":[1,2,3]}}}\n';
for (const ch of full) parser.push(ch);
assert.equal(objects.length, 1);
assert.deepEqual(objects[0].payload.deep.nested, [1, 2, 3]);
});
it("ignores blank lines between objects", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"a"}\n\n\n{"type":"b"}\n');
assert.equal(objects.length, 2);
assert.equal(errors.length, 0);
});
it("reports malformed JSON via onError without throwing", () => {
const { parser, objects, errors } = collect();
parser.push("not valid json\n");
parser.push('{"type":"ok"}\n');
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "ok");
assert.equal(errors.length, 1);
assert.match(errors[0].raw, /not valid json/);
});
it("does not emit a partial line until newline arrives", () => {
const { parser, objects } = collect();
parser.push('{"type":"unfinished"');
assert.equal(objects.length, 0);
parser.push("}\n");
assert.equal(objects.length, 1);
});
it("flush() emits trailing line without newline", () => {
const { parser, objects } = collect();
parser.push('{"type":"trailing"}');
assert.equal(objects.length, 0);
parser.flush();
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "trailing");
});
it("flush() on empty buffer is a no-op", () => {
const { parser, objects, errors } = collect();
parser.flush();
assert.equal(objects.length, 0);
assert.equal(errors.length, 0);
});
it("flush() reports malformed trailing line via onError", () => {
const { parser, objects, errors } = collect();
parser.push("garbage{not-json");
parser.flush();
assert.equal(objects.length, 0);
assert.equal(errors.length, 1);
});
it("works without onError callback when input is malformed", () => {
let count = 0;
const parser = createLineParser((_o) => count++);
// No throw expected.
parser.push("garbage\n");
parser.push('{"type":"ok"}\n');
assert.equal(count, 1);
});
it("handles CRLF line endings cleanly (\\r is trimmed before parse)", () => {
const { parser, objects, errors } = collect();
parser.push('{"type":"crlf"}\r\n');
// Note: parser only splits on \n; the \r at end of line stays in the
// line. JSON.parse tolerates trailing whitespace including \r.
assert.equal(errors.length, 0);
assert.equal(objects.length, 1);
assert.equal(objects[0].type, "crlf");
});
it("handles a stream-json envelope with stream_event sub-event shape", () => {
const { parser, objects } = collect();
const env = JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hello" },
},
session_id: "sess",
});
parser.push(env + "\n");
assert.equal(objects.length, 1);
assert.equal(objects[0].event.delta.text, "Hello");
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* @file tmux.test.js
* @description Unit tests for the tmux command wrapper. Injects a fake exec
* implementation so the suite never shells out to a real `tmux` binary (CI
* has none installed).
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const tmux = require("../lib/tmux");
describe("tmux wrapper", () => {
beforeEach(() => {
tmux.__reset();
});
it("hasSession returns true when execFileSync exits 0", () => {
tmux.__setExecImpl(() => "");
assert.equal(tmux.hasSession("ccam-lane-1"), true);
});
it("hasSession returns false when execFileSync throws", () => {
tmux.__setExecImpl(() => {
const e = new Error("no such session");
e.status = 1;
throw e;
});
assert.equal(tmux.hasSession("ccam-lane-1"), false);
});
it("newSession builds the correct argv", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
tmux.newSession({ name: "ccam-lane-1", cwd: "/tmp/repo", argv: ["claude", "--model", "opus"] });
assert.deepEqual(calls[0], [
"new-session",
"-d",
"-s",
"ccam-lane-1",
"-c",
"/tmp/repo",
"--",
"claude",
"--model",
"opus",
]);
});
it("killSession never throws when the session is already gone", () => {
tmux.__setExecImpl(() => {
const e = new Error("no such session");
e.status = 1;
throw e;
});
assert.doesNotThrow(() => tmux.killSession("ccam-lane-1"));
});
it("listSessions filters by prefix and ignores unrelated sessions", () => {
tmux.__setExecImpl(() => "ccam-lane-1\nccam-lane-2\nsome-other-session\n");
assert.deepEqual(tmux.listSessions("ccam-lane-"), ["ccam-lane-1", "ccam-lane-2"]);
});
it("listSessions returns [] when tmux has no sessions at all (exit 1)", () => {
tmux.__setExecImpl(() => {
const e = new Error("no server running");
e.status = 1;
throw e;
});
assert.deepEqual(tmux.listSessions("ccam-lane-"), []);
});
it("isTmuxAvailable reflects whether the binary resolves on PATH", () => {
tmux.__setExecImpl(() => "tmux 3.4");
assert.equal(tmux.isTmuxAvailable(), true);
tmux.__setExecImpl(() => {
throw new Error("ENOENT");
});
assert.equal(tmux.isTmuxAvailable(), false);
});
});
+26 -2
View File
@@ -14,11 +14,28 @@ const { execFileSync } = require("child_process");
const { getUpdatesStatus } = require("../lib/update-check"); const { getUpdatesStatus } = require("../lib/update-check");
// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
// very suite) sets in its own environment — those leak to every child
// process and override an explicit `cwd`, so without this a git command
// meant for this test's throwaway tmp repo silently operates on the real
// repo running the hook instead.
const GIT_ENV = { ...process.env };
delete GIT_ENV.GIT_DIR;
delete GIT_ENV.GIT_WORK_TREE;
delete GIT_ENV.GIT_INDEX_FILE;
delete GIT_ENV.GIT_COMMON_DIR;
delete GIT_ENV.GIT_OBJECT_DIRECTORY;
delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete GIT_ENV.GIT_PREFIX;
delete GIT_ENV.GIT_NAMESPACE;
delete GIT_ENV.GIT_CONFIG_PARAMETERS;
function git(cwd, args) { function git(cwd, args) {
return execFileSync("git", args, { return execFileSync("git", args, {
cwd, cwd,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8", encoding: "utf8",
env: GIT_ENV,
}).trim(); }).trim();
} }
@@ -29,6 +46,7 @@ function makeBareRemote(parent, name) {
// i.e. far older than --initial-branch. // i.e. far older than --initial-branch.
execFileSync("git", ["-c", "init.defaultBranch=master", "init", "--bare", repo], { execFileSync("git", ["-c", "init.defaultBranch=master", "init", "--bare", repo], {
stdio: "ignore", stdio: "ignore",
env: GIT_ENV,
}); });
return repo; return repo;
} }
@@ -36,7 +54,10 @@ function makeBareRemote(parent, name) {
function makeWorkingRepo(parent, dir, originUrl) { function makeWorkingRepo(parent, dir, originUrl) {
const repo = path.join(parent, dir); const repo = path.join(parent, dir);
fs.mkdirSync(repo, { recursive: true }); fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], { stdio: "ignore" }); execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], {
stdio: "ignore",
env: GIT_ENV,
});
fs.writeFileSync(path.join(repo, "README.md"), "fixture\n"); fs.writeFileSync(path.join(repo, "README.md"), "fixture\n");
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]); git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]);
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]); git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]);
@@ -146,7 +167,10 @@ describe("getUpdatesStatus — no remotes configured", () => {
it("returns a soft no-remotes payload", async () => { it("returns a soft no-remotes payload", async () => {
const repo = path.join(tmpDir, "noremote"); const repo = path.join(tmpDir, "noremote");
fs.mkdirSync(repo, { recursive: true }); fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], { stdio: "ignore" }); execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], {
stdio: "ignore",
env: GIT_ENV,
});
fs.writeFileSync(path.join(repo, "README.md"), "lonely\n"); fs.writeFileSync(path.join(repo, "README.md"), "lonely\n");
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]); git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]);
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]); git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]);
+10 -1
View File
@@ -248,7 +248,7 @@ db.exec(`
CREATE TABLE IF NOT EXISTS dashboard_runs ( CREATE TABLE IF NOT EXISTS dashboard_runs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
session_id TEXT, session_id TEXT,
mode TEXT NOT NULL, mode TEXT,
cwd TEXT NOT NULL, cwd TEXT NOT NULL,
model TEXT, model TEXT,
permission_mode TEXT, permission_mode TEXT,
@@ -496,6 +496,15 @@ try {
} }
db.prepare("CREATE INDEX IF NOT EXISTS idx_dashboard_runs_lane ON dashboard_runs(lane_id)").run(); db.prepare("CREATE INDEX IF NOT EXISTS idx_dashboard_runs_lane ON dashboard_runs(lane_id)").run();
// Migrate: tmux session name backing this run (tmux+PTY terminal design,
// 2026-08-11). Additive and nullable — historical rows spawned via the old
// stream-json mode have no tmux session and are display-only history now.
try {
db.prepare("SELECT tmux_session FROM dashboard_runs LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN tmux_session TEXT").run();
}
// Migrate: add stage-detection columns to lanes. Inference is never evidence — // Migrate: add stage-detection columns to lanes. Inference is never evidence —
// these are additive columns separate from `stage` (the declared stage), so // these are additive columns separate from `stage` (the declared stage), so
// `stage`'s meaning is untouched and turning the feature off loses nothing. // `stage`'s meaning is untouched and turning the feature off loses nothing.
+2 -1
View File
@@ -33,7 +33,7 @@ const cors = require("cors");
const path = require("path"); const path = require("path");
const http = require("http"); const http = require("http");
const swaggerUi = require("swagger-ui-express"); const swaggerUi = require("swagger-ui-express");
const { initWebSocket } = require("./websocket"); const { initWebSocket, initPtyWebSocket } = require("./websocket");
const { createOpenApiSpec } = require("./openapi"); const { createOpenApiSpec } = require("./openapi");
const { redocBundlePath, renderRedocHtml } = require("./lib/redoc"); const { redocBundlePath, renderRedocHtml } = require("./lib/redoc");
const { writeServerInfo, removeServerInfo, peersSharingDataDir } = require("./lib/server-info"); const { writeServerInfo, removeServerInfo, peersSharingDataDir } = require("./lib/server-info");
@@ -150,6 +150,7 @@ function createApp() {
function startServer(app, port) { function startServer(app, port) {
const server = http.createServer(app); const server = http.createServer(app);
initWebSocket(server); initWebSocket(server);
initPtyWebSocket(server);
const isProduction = process.env.NODE_ENV === "production"; const isProduction = process.env.NODE_ENV === "production";
if (isProduction) { if (isProduction) {
+4 -3
View File
@@ -120,8 +120,9 @@ function excludeFromGit(laneDir, line) {
* @returns {{servers: string[], profilesSeeded: string[]}} * @returns {{servers: string[], profilesSeeded: string[]}}
*/ */
async function syncMcp(lane) { async function syncMcp(lane) {
const sourceServers = readSourceMcpServers(lane.source_repo); const sourceRepo = lane.source_repo || lane.cwd;
const relocated = relocate(sourceServers, lane.source_repo, lane.cwd); const sourceServers = readSourceMcpServers(sourceRepo);
const relocated = relocate(sourceServers, sourceRepo, lane.cwd);
pinPlaywrightOutputDir(relocated, lane.cwd); pinPlaywrightOutputDir(relocated, lane.cwd);
fs.writeFileSync( fs.writeFileSync(
@@ -130,7 +131,7 @@ async function syncMcp(lane) {
); );
excludeFromGit(lane.cwd, ".mcp.json"); excludeFromGit(lane.cwd, ".mcp.json");
const profilesSeeded = seedProfiles(lane.source_repo, lane.cwd); const profilesSeeded = seedProfiles(sourceRepo, lane.cwd);
return { servers: Object.keys(relocated), profilesSeeded }; return { servers: Object.keys(relocated), profilesSeeded };
} }
+124
View File
@@ -0,0 +1,124 @@
/**
* @file pty-attach.js
* @description Bridges one WebSocket connection to an `@lydell/node-pty`-backed
* `tmux attach-session` process. Binary WS frames carry raw PTY bytes in
* both directions; text WS frames carry small JSON control messages
* (`resize`, and an outbound `exit` sent once when the pane process/tmux
* session ends). Multiple browser tabs each get their own PTY attach
* process tmux itself is what keeps them all in sync, this module does no
* cross-connection coordination.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const RUN_ID_RE = /^ccam-lane-\d+$/;
/**
* Reject anything that isn't exactly `ccam-lane-<digits>` before it can ever
* reach a tmux/PTY command the trust boundary for this WS path, since a
* validated runId is the only thing standing between an authenticated WS
* client and naming an arbitrary session on the host.
*/
function validateRunId(runId) {
if (typeof runId !== "string" || !RUN_ID_RE.test(runId)) {
const err = new Error(`EBADRUNID: invalid runId: ${runId}`);
err.code = "EBADRUNID";
throw err;
}
}
// Test seam — real implementation set in Step 4.
let spawnImpl = null;
function __setSpawnImpl(fn) {
spawnImpl = fn;
}
/**
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
* `tmux attach-session -t <runId>` per call.
*/
function attach(ws, runId, { cols, rows }) {
validateRunId(runId);
const pty = spawnImpl("tmux", ["attach-session", "-t", runId], {
name: "xterm-256color",
cols: cols || 80,
rows: rows || 24,
});
pty.onData((data) => {
try {
ws.send(data, { binary: true });
} catch {
/* client gone between data event and send — safe to ignore */
}
});
pty.onExit(({ exitCode }) => {
try {
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
} catch {
/* ignore */
}
try {
ws.close();
} catch {
/* ignore */
}
});
ws.on("message", (data, isBinary) => {
// node-pty attach processes for `tmux attach` are already tolerant of
// resize mid-stream; the `ws` lib passes isBinary either as the second
// callback arg (newer) or via `data.binary` on some transports — this
// helper's own tests exercise the `{binary}` option shape used above.
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
const text = data.toString("utf8");
if (binary) {
pty.write(text);
return;
}
// The browser's WebSocket API sends a JS string as a text frame, and
// xterm.js's onData callback hands over plain strings — so every
// keystroke arrives here as text, not binary. Only a JSON control frame
// (matched by this same prefix check the client uses for output) is
// NOT keystroke input; everything else must reach the pty or typing
// does nothing.
if (text.startsWith('{"type"')) {
let msg;
try {
msg = JSON.parse(text);
} catch {
pty.write(text);
return;
}
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
pty.resize(msg.cols, msg.rows);
return;
}
}
pty.write(text);
});
ws.on("close", () => {
try {
pty.kill();
} catch {
/* already gone */
}
});
return pty;
}
// Real spawn implementation — lazy-required so unit tests never load the
// native PTY addon unless they explicitly opt in. Uses @lydell/node-pty (a
// drop-in-API-compatible fork of node-pty) rather than node-pty itself:
// node-pty ships prebuilt binaries for darwin/win32 only, so on Linux it
// needs a native build via its install script — but the plugin install path
// runs `npm install --ignore-scripts` deliberately (see plugin-bootstrap.js)
// to avoid requiring a build toolchain on the user's machine. @lydell/node-pty
// instead ships the platform binary as a regular optionalDependency
// (@lydell/node-pty-linux-x64 etc.), so a plain --ignore-scripts install still
// resolves a working native binding with no compiler needed.
__setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args));
module.exports = { attach, validateRunId, __setSpawnImpl };
+176
View File
@@ -0,0 +1,176 @@
/**
* @file pty-run.js
* @description Owns the tmux-backed run lifecycle for the dashboard's
* terminal-run feature: Start (create-or-adopt), Resume (`--resume`), Kill,
* and List. Unlike the old run-spawner.js, there is no in-memory handle Map
* `listRuns`/`getRun` are computed fresh from `tmux list-sessions` on every
* call, the same "computed fact, never a stored one" principle this repo
* already applies to lane runtime up/down (see CLAUDE.md). A session killed
* out-of-band (crash, manual `tmux kill-session`, host reboot) self-corrects
* on the next read instead of leaving a ghost "running" row.
*
* Every session is named `ccam-lane-<laneId>` so a real terminal can attach
* to the exact same session (`tmux attach -t ccam-lane-<id>`, or
* `ccam lanes shell`) that's the whole point: the dashboard both creates
* the session (one-click Start/Resume) and is just one of possibly several
* attached clients tmux already keeps in sync.
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const tmux = require("./tmux");
let dashboardRuns = null;
try {
dashboardRuns = require("./dashboard-runs");
} catch {
/* db-less environment, skip persistence */
}
const RUN_ID_RE = /^ccam-lane-(\d+)$/;
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
function runIdForLane(laneId) {
return `ccam-lane-${laneId}`;
}
function laneIdFromRunId(id) {
const m = typeof id === "string" ? id.match(RUN_ID_RE) : null;
return m ? Number(m[1]) : null;
}
function makeErr(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/**
* Build the pane's command argv. Unlike the old stream-json spawner there is
* no headless/conversation split every run is a live interactive pane, so
* an initial prompt (when given) is a trailing POSITIONAL argument: `claude`
* treats a bare positional as the first turn's message and stays interactive
* afterward (unlike `-p`, which forces print-and-exit and closes stdin).
*/
function buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) {
const argv = ["claude"];
argv.push("--permission-mode", permissionMode || "acceptEdits");
if (model) argv.push("--model", model);
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
if (resumeSessionId) argv.push("--resume", resumeSessionId);
if (initialPrompt) argv.push(initialPrompt);
return argv;
}
/**
* @param {object} args
* @param {number} args.laneId
* @param {string} args.cwd
* @param {string} [args.model]
* @param {string} [args.permissionMode]
* @param {string} [args.effort]
* @param {string} [args.resumeSessionId]
* @param {string} [args.initialPrompt]
*/
function spawnRun(args) {
const { laneId, cwd, model, permissionMode, effort, resumeSessionId, initialPrompt } = args || {};
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
throw makeErr("EBADLANE", "laneId must be an integer");
}
if (typeof cwd !== "string" || !cwd) {
throw makeErr("EBADCWD", "cwd is required");
}
if (permissionMode != null && !ALLOWED_PERMISSION_MODES.has(permissionMode)) {
throw makeErr(
"EBADMODE",
`permissionMode must be one of: ${Array.from(ALLOWED_PERMISSION_MODES).join(", ")}`
);
}
if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) {
throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`);
}
if (
resumeSessionId != null &&
(typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId))
) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
const id = runIdForLane(laneId);
const startedAt = Date.now();
if (!tmux.hasSession(id)) {
const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt });
tmux.newSession({ name: id, cwd, argv });
if (dashboardRuns) {
dashboardRuns.recordRun({
id,
sessionId: resumeSessionId || null,
mode: null,
cwd,
model: model || null,
permissionMode: permissionMode || "acceptEdits",
effort: effort || null,
resumeSessionId: resumeSessionId || null,
prompt: initialPrompt || "",
status: "running",
startedAt,
endedAt: null,
exitCode: null,
laneId,
});
}
}
// Already running: adopt silently, same convention as this repo's server
// port-adoption logic — no error, no duplicate session.
return getRun(id);
}
function killRun(id) {
if (!id || !tmux.hasSession(id)) return false;
tmux.killSession(id);
if (dashboardRuns) {
dashboardRuns.patchRun({ id, status: "killed", endedAt: Date.now() });
}
return true;
}
function publicRun(id) {
const laneId = laneIdFromRunId(id);
const live = tmux.hasSession(id);
const row = dashboardRuns ? dashboardRuns.getRun(id) : null;
return {
id,
laneId,
status: live ? "running" : "gone",
cwd: row?.cwd || null,
model: row?.model || null,
permissionMode: row?.permission_mode || null,
effort: row?.effort || null,
resumeSessionId: row?.resume_session_id || null,
sessionId: row?.session_id || null,
startedAt: row?.started_at || null,
promptPreview: row?.prompt_preview || null,
};
}
function getRun(id) {
if (!id) return null;
return publicRun(id);
}
/** Computed fresh from tmux state every call — see file header. */
function listRuns() {
return tmux.listSessions("ccam-lane-").map(publicRun);
}
module.exports = {
spawnRun,
killRun,
getRun,
listRuns,
laneIdFromRunId,
runIdForLane,
};
-567
View File
@@ -1,567 +0,0 @@
/**
* @file run-spawner.js
* @description Spawns and supervises Claude Code subprocesses for the
* dashboard's Run page. Two modes:
* - "headless" single-shot. Stdin is closed after spawn; the prompt
* lives in argv via `-p`. Process exits when the model
* finishes the turn.
* - "conversation" multi-turn. Stdin stays open; follow-up turns are
* delivered via JSON envelopes through stdin and the
* caller can pipe more messages until they kill or the
* child exits naturally.
*
* Conversation mode also supports resuming an existing session via
* `--resume <session-id>`, so the user can continue any prior Claude Code
* conversation from inside the dashboard.
*
* Output is always `--output-format stream-json --verbose` so the parser can
* deliver structured envelopes (system/init, assistant text+tool_use, user
* tool_result, result/success, etc). Each envelope is broadcast over the
* dashboard's existing WebSocket as a `run_stream` message; status changes
* (spawning running completed/error/killed) broadcast as `run_status`.
*
* Concurrency is capped (RUN_MAX_CONCURRENT, default 10) over the cap we
* throw ECONCURRENCY with the running set so the route can return 429.
*
* When a child truly finishes (real exit, or a spawn that never started) the
* handler registered via setRunExitHandler is called once. That inversion is
* how a lane gets released without this module requiring the lane router back.
*
* Each handle keeps a bounded in-memory envelope log (cap 500) so a client
* that attaches late can replay what it missed. Completed handles are reaped
* after 5 min but the underlying transcripts persist via the normal hook
* ingestion pipeline (every spawned `claude` fires hooks like any other
* session, so the run shows up in /sessions automatically).
*
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
// cross-spawn (not node:child_process): on Windows the npm-installed `claude`
// is a `.cmd` shim that plain spawn can't launch, and the naive fix (`shell:
// true`) would run argv — including the user-controlled prompt/model — through
// cmd.exe, opening a command-injection hole. cross-spawn resolves the shim and
// escapes arguments safely without a shell. On macOS/Linux it is a plain spawn.
const spawn = require("cross-spawn");
const { randomUUID } = require("node:crypto");
const { broadcast } = require("../websocket");
const { createLineParser } = require("./stream-json-parser");
// Persistence is best-effort and optional — load lazily so unit tests that
// don't bring up the full db can still exercise the spawner.
let dashboardRuns = null;
try {
dashboardRuns = require("./dashboard-runs");
} catch {
/* db-less environment, skip persistence */
}
function recordRun(handle) {
if (dashboardRuns) dashboardRuns.recordRun(handle);
}
function patchRun(args) {
if (dashboardRuns) dashboardRuns.patchRun(args);
}
// Whoever owns lanes registers here at boot (routes/lanes.js) so a finished run
// can release its lane. The dependency is inverted deliberately: the lane router
// already requires THIS module, and releasing needs the router's lanePayload /
// lastEventAge to broadcast — requiring it back would be a cycle.
let runExitHandler = null;
function setRunExitHandler(fn) {
runExitHandler = typeof fn === "function" ? fn : null;
}
/** Announce a truly-exited run. Never lets a listener break run bookkeeping. */
function notifyRunExit(handle) {
if (!runExitHandler) return;
try {
runExitHandler({ runId: handle.id, laneId: handle.laneId || null });
} catch {
/* a broken listener is not the run's problem */
}
}
// Effectively uncapped — claude's terminal TUI doesn't gate concurrent
// sessions, so we don't either. The number is high enough that a buggy
// client still can't fork-bomb the host before someone notices, but low
// enough that no human will ever hit it organically. Users who want a
// real cap can set RUN_MAX_CONCURRENT.
const MAX_CONCURRENT_DEFAULT = 10000;
const REAP_AFTER_MS = 5 * 60 * 1000; // keep handle for 5 min after exit
const STDOUT_TAIL_BYTES = 4 * 1024;
const STDERR_TAIL_BYTES = 4 * 1024;
// Cap stored envelopes per handle so a long-running conversation doesn't
// balloon memory. Late-attaching clients get this much history; the full
// transcript is always available via the existing /sessions/<id> view.
const MAX_ENVELOPES_PER_HANDLE = 500;
const handles = new Map();
const reapers = new Map();
function getMaxConcurrent() {
const raw = process.env.RUN_MAX_CONCURRENT;
if (!raw) return MAX_CONCURRENT_DEFAULT;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : MAX_CONCURRENT_DEFAULT;
}
function liveCount() {
let n = 0;
for (const h of handles.values()) {
if (h.status === "spawning" || h.status === "running") n++;
}
return n;
}
function tail(s, n) {
if (typeof s !== "string") return "";
if (s.length <= n) return s;
return s.slice(s.length - n);
}
/**
* Build argv for the `claude` invocation. The two modes have different argv
* shapes because of how Claude Code resolves the first user message:
*
* - HEADLESS: `-p "<prompt>"` carries the prompt; stdin is closed; Claude
* processes one turn and exits.
* - CONVERSATION: `--input-format stream-json` puts Claude in multi-turn
* mode where ALL user turns (including the first) come via stdin. When
* stream-json input is enabled, `-p` is silently ignored so we OMIT
* it and send the initial prompt over stdin in `spawnRun` immediately
* after the spawn handshake.
*/
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
function buildArgv({ prompt, mode, model, permissionMode, resumeSessionId, effort }) {
const argv = [];
argv.push("--output-format", "stream-json");
argv.push("--verbose");
// Real character-by-character streaming. Without this flag Claude only
// emits the *final* assistant envelope, which makes the UI feel like the
// response arrives all at once. With it, we also receive `stream_event`
// envelopes (Anthropic Messages API streaming events) so the UI can
// render text + thinking deltas as they arrive.
argv.push("--include-partial-messages");
argv.push("--permission-mode", permissionMode || "acceptEdits");
if (mode === "headless") {
argv.push("-p", prompt);
} else {
argv.push("--input-format", "stream-json");
}
if (model) {
argv.push("--model", model);
}
if (effort && EFFORT_LEVELS.has(effort)) {
// Drives thinking depth: higher = more reasoning tokens before the
// assistant turn. Empty / unset means "inherit from the model's default".
argv.push("--effort", effort);
}
if (resumeSessionId) {
argv.push("--resume", resumeSessionId);
}
return argv;
}
/**
* Frame a stream-json user envelope. Used both for the initial conversation-
* mode prompt and for follow-up turns via sendInput.
*/
function userEnvelope(text, id) {
const e = {
type: "user",
message: { role: "user", content: text },
};
if (id) e.id = id;
return JSON.stringify(e) + "\n";
}
/**
* Strip dashboard-internal env vars from the child so the spawned `claude`
* doesn't accidentally pick up our hook-handler context (and to keep the
* child's auth entirely from the user's existing OAuth in $HOME).
*/
function cleanSpawnEnv() {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST;
return env;
}
function attachStreamHandlers(handle) {
const parser = createLineParser(
(envelope) => {
// First parsed envelope means the child is producing output → "running".
if (handle.status === "spawning") {
handle.status = "running";
broadcast("run_status", { id: handle.id, status: "running", at: Date.now() });
patchRun({ id: handle.id, status: "running" });
}
// Capture session_id off the system/init envelope — once we have it the
// dashboard can deep-link to /sessions/<id> on completion.
if (
envelope &&
envelope.type === "system" &&
envelope.subtype === "init" &&
typeof envelope.session_id === "string"
) {
const wasNull = !handle.sessionId;
handle.sessionId = envelope.session_id;
if (wasNull) patchRun({ id: handle.id, sessionId: envelope.session_id });
}
handle.envelopeCount += 1;
handle.envelopes.push(envelope);
// Keep only the most recent N — older entries are still in the disk
// transcript at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl,
// visible via the regular /sessions/<id> dashboard view.
if (handle.envelopes.length > MAX_ENVELOPES_PER_HANDLE) {
handle.envelopes.splice(0, handle.envelopes.length - MAX_ENVELOPES_PER_HANDLE);
}
broadcast("run_stream", { id: handle.id, envelope });
},
(err, raw) => {
handle.stderrBuffer += `[parse-error] ${err.message}: ${raw}\n`;
}
);
handle.child.stdout.on("data", (chunk) => {
const s = chunk.toString("utf8");
handle.stdoutBuffer = tail(handle.stdoutBuffer + s, STDOUT_TAIL_BYTES);
parser.push(s);
});
handle.child.stderr.on("data", (chunk) => {
handle.stderrBuffer = tail(handle.stderrBuffer + chunk.toString("utf8"), STDERR_TAIL_BYTES);
});
handle.child.on("error", (err) => {
// A spawn error has no corresponding `exit` event: the OS never started
// the child, so it can no longer touch the lane directory.
handle.actualExitedAt = Date.now();
handle.status = "error";
handle.error = err.message;
handle.endedAt = Date.now();
broadcast("run_status", {
id: handle.id,
status: "error",
error: err.message,
at: handle.endedAt,
});
patchRun({ id: handle.id, status: "error", endedAt: handle.endedAt });
scheduleReap(handle.id);
// A spawn that never started is just as finished as one that ran: without
// this the lane stays `running` forever with a dead run_id.
notifyRunExit(handle);
});
handle.child.on("exit", (code, signal) => {
parser.flush();
// `killRun` deliberately sets status to `killed` immediately after it
// requests SIGTERM. Keep this separate, exit-only signal so callers that
// must not touch a run's cwd until the OS reaps it can wait truthfully.
handle.actualExitedAt = Date.now();
if (handle.status === "killed") {
// already broadcast — patchRun already happened in stop()
} else {
handle.status = code === 0 ? "completed" : "error";
handle.exitCode = code;
handle.signal = signal;
handle.endedAt = Date.now();
broadcast("run_status", {
id: handle.id,
status: handle.status,
exitCode: code,
sessionId: handle.sessionId || null,
at: handle.endedAt,
});
patchRun({
id: handle.id,
status: handle.status,
exitCode: code,
sessionId: handle.sessionId || null,
endedAt: handle.endedAt,
});
}
scheduleReap(handle.id);
// Fires for a killed run too — killRun only flags `killed` before the OS
// reaps the child; a killed run is a finished run.
notifyRunExit(handle);
});
}
function scheduleReap(id) {
const existing = reapers.get(id);
if (existing) clearTimeout(existing);
const t = setTimeout(() => {
handles.delete(id);
reapers.delete(id);
}, REAP_AFTER_MS);
// Don't keep the process alive just for the reap timer.
if (typeof t.unref === "function") t.unref();
reapers.set(id, t);
}
/**
* @param {object} args
* @param {string} args.prompt
* @param {"headless"|"conversation"} args.mode
* @param {string} [args.cwd]
* @param {string} [args.model]
* @param {string} [args.permissionMode]
* @param {number} [args.laneId] Lane this run was started through; persisted so
* the Workspace page can list one lane's runs. Omitted by POST /api/run.
* @returns handle
*/
function spawnRun(args) {
const { prompt, mode, cwd, model, permissionMode, resumeSessionId, effort, laneId } = args || {};
if (typeof prompt !== "string") {
throw makeErr("EBADPROMPT", "prompt is required");
}
// Empty prompt is allowed only when resuming a conversation — claude
// idles on the resumed transcript until the user types a follow-up.
if (!prompt.trim() && !(mode === "conversation" && resumeSessionId)) {
throw makeErr("EBADPROMPT", "prompt is required");
}
if (mode !== "headless" && mode !== "conversation") {
throw makeErr("EBADMODE", `mode must be "headless" or "conversation"`);
}
if (effort != null && effort !== "" && !EFFORT_LEVELS.has(effort)) {
throw makeErr("EBADEFFORT", `effort must be one of: ${Array.from(EFFORT_LEVELS).join(", ")}`);
}
if (resumeSessionId != null) {
if (typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId)) {
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
}
// Resume only makes sense in conversation mode (you want to keep talking).
// Headless `claude --resume` does run, but the UX of "send one prompt and
// exit" on a resumed session is confusing — disallow.
if (mode !== "conversation") {
throw makeErr("EBADMODE", "resumeSessionId requires conversation mode");
}
}
const max = getMaxConcurrent();
if (liveCount() >= max) {
const err = makeErr("ECONCURRENCY", `concurrency limit ${max} reached`);
err.running = Array.from(handles.values())
.filter((h) => h.status === "running" || h.status === "spawning")
.map((h) => ({ id: h.id, pid: h.pid, startedAt: h.startedAt, mode: h.mode }));
throw err;
}
const id = randomUUID();
const argv = buildArgv({ prompt, mode, model, permissionMode, resumeSessionId, effort });
// cross-spawn handles the Windows `.cmd` shim safely (see the require above);
// deliberately no `shell` option, so argv is never parsed by cmd.exe.
const child = spawn("claude", argv, {
env: cleanSpawnEnv(),
cwd: cwd || process.cwd(),
stdio: ["pipe", "pipe", "pipe"],
});
const handle = {
id,
pid: child.pid || null,
mode,
cwd: cwd || process.cwd(),
model: model || null,
permissionMode: permissionMode || "acceptEdits",
effort: effort || null,
prompt,
argv,
resumeSessionId: resumeSessionId || null,
laneId: typeof laneId === "number" ? laneId : null,
status: "spawning",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
actualExitedAt: null,
sessionId: resumeSessionId || null, // optimistic; will be confirmed by system/init envelope
envelopeCount: 0,
envelopes: [],
stdoutBuffer: "",
stderrBuffer: "",
child,
};
handles.set(id, handle);
recordRun(handle);
attachStreamHandlers(handle);
if (mode === "headless") {
// Headless: prompt is in argv; close stdin so Claude knows nothing more
// is coming and exits after the one turn.
try {
child.stdin.end();
} catch {
/* ignore */
}
} else if (prompt && prompt.trim()) {
// Conversation: deliver the initial prompt over stdin so Claude in
// stream-json input mode actually starts processing it. Stdin stays
// open for follow-up turns.
try {
child.stdin.write(userEnvelope(prompt));
} catch (err) {
handle.stderrBuffer += `[stdin-write-error] ${err.message}\n`;
}
}
// Conversation with empty prompt (resume scenarios) — leave stdin open;
// claude will idle on the resumed conversation until the user types a
// follow-up via POST /:id/message.
broadcast("run_status", { id, status: "spawning", at: handle.startedAt });
return handle;
}
/**
* Send a follow-up user turn into a running conversation. Throws if the
* handle is not running, not in conversation mode, or stdin is closed.
*/
function sendInput(id, text) {
const handle = handles.get(id);
if (!handle) throw makeErr("ENOTFOUND", "run not found");
if (handle.mode !== "conversation") {
throw makeErr("EWRONGMODE", "only conversation mode accepts follow-up input");
}
if (handle.status !== "running" && handle.status !== "spawning") {
throw makeErr("ENOTRUNNING", `run is ${handle.status}`);
}
if (typeof text !== "string" || !text) {
throw makeErr("EBADINPUT", "text is required");
}
if (!handle.child || !handle.child.stdin || !handle.child.stdin.writable) {
throw makeErr("ESTDINCLOSED", "stdin is not writable");
}
const messageId = randomUUID();
handle.child.stdin.write(userEnvelope(text, messageId));
broadcast("run_input_ack", { id, messageId, at: Date.now() });
return { messageId };
}
function killRun(id) {
const handle = handles.get(id);
if (!handle) return false;
if (handle.status === "completed" || handle.status === "error" || handle.status === "killed") {
return true;
}
if (handle.child && !handle.child.killed) {
try {
handle.child.kill("SIGTERM");
} catch {
/* ignore */
}
setTimeout(() => {
const h = handles.get(id);
if (h && h.child && !h.actualExitedAt) {
try {
h.child.kill("SIGKILL");
} catch {
/* ignore */
}
}
}, 5000).unref?.();
}
handle.status = "killed";
handle.endedAt = Date.now();
broadcast("run_status", { id, status: "killed", at: handle.endedAt });
patchRun({ id, status: "killed", endedAt: handle.endedAt });
scheduleReap(id);
return true;
}
function publicHandle(handle, opts = {}) {
if (!handle) return null;
const out = {
id: handle.id,
pid: handle.pid,
mode: handle.mode,
cwd: handle.cwd,
model: handle.model,
permissionMode: handle.permissionMode,
effort: handle.effort || null,
prompt: handle.prompt,
argv: handle.argv,
resumeSessionId: handle.resumeSessionId || null,
status: handle.status,
startedAt: handle.startedAt,
endedAt: handle.endedAt,
exitCode: handle.exitCode,
signal: handle.signal,
error: handle.error,
actualExitedAt: handle.actualExitedAt,
sessionId: handle.sessionId,
envelopeCount: handle.envelopeCount,
stdoutTail: handle.stdoutBuffer,
stderrTail: handle.stderrBuffer,
};
if (opts.includeEnvelopes) {
out.envelopes = handle.envelopes.slice();
}
return out;
}
function getRun(id, opts = {}) {
return publicHandle(handles.get(id), opts);
}
function listRuns() {
return Array.from(handles.values())
.sort((a, b) => b.startedAt - a.startedAt)
.map(publicHandle);
}
function makeErr(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
// Test seam: inject a fake child (e.g. PassThrough streams) without invoking
// the real `claude` binary. Returns the handle.
function __injectChildForTest({ child, mode = "conversation", prompt = "test" }) {
const id = randomUUID();
const handle = {
id,
pid: 0,
mode,
cwd: process.cwd(),
model: null,
permissionMode: "acceptEdits",
effort: null,
prompt,
argv: ["-p", prompt],
resumeSessionId: null,
status: "spawning",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
actualExitedAt: null,
sessionId: null,
envelopeCount: 0,
envelopes: [],
stdoutBuffer: "",
stderrBuffer: "",
child,
};
handles.set(id, handle);
attachStreamHandlers(handle);
return handle;
}
function __reset() {
for (const t of reapers.values()) clearTimeout(t);
reapers.clear();
handles.clear();
}
module.exports = {
spawnRun,
setRunExitHandler,
sendInput,
killRun,
getRun,
listRuns,
liveCount,
getMaxConcurrent,
__injectChildForTest,
__reset,
};
-40
View File
@@ -1,40 +0,0 @@
/**
* @file stream-json-parser.js
* @description Newline-delimited JSON line buffer for parsing `claude
* --output-format stream-json` output. Reassembles arbitrarily chunked stdout
* into discrete JSON envelopes (one per line). Robust to partial writes;
* malformed lines are reported via onError but never throw.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
function createLineParser(onObject, onError) {
let buf = "";
return {
push(chunk) {
buf += chunk;
let nlIdx;
while ((nlIdx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nlIdx).trim();
buf = buf.slice(nlIdx + 1);
if (!line) continue;
try {
onObject(JSON.parse(line));
} catch (err) {
if (typeof onError === "function") onError(err, line);
}
}
},
flush() {
const tail = buf.trim();
buf = "";
if (!tail) return;
try {
onObject(JSON.parse(tail));
} catch (err) {
if (typeof onError === "function") onError(err, tail);
}
},
};
}
module.exports = { createLineParser };
+82
View File
@@ -0,0 +1,82 @@
/**
* @file tmux.js
* @description Thin wrapper around the `tmux` CLI for the terminal-run
* feature. Every dashboard-managed session is named `ccam-lane-<id>` (see
* `pty-run.js`) so a real terminal can attach to the exact same session with
* `tmux attach -t ccam-lane-<id>` (or `ccam lanes shell`). Never builds a
* shell string every call is `execFileSync("tmux", [...argv])` with an
* explicit argument array (matches this repo's rule for git in worktree.js).
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
const { execFileSync } = require("node:child_process");
// Test seam: swap the exec implementation so unit tests never invoke a real
// tmux binary. Mirrors run-spawner.js's __injectChildForTest/__reset style.
let execImpl = (args) => execFileSync("tmux", args, { encoding: "utf8" });
function __setExecImpl(fn) {
execImpl = fn;
}
function __reset() {
execImpl = (args) => execFileSync("tmux", args, { encoding: "utf8" });
}
function hasSession(name) {
try {
execImpl(["has-session", "-t", name]);
return true;
} catch {
return false;
}
}
/**
* Create a detached tmux session running `argv` as the pane's command. Throws
* if tmux itself fails to start (caller decides how to surface that).
*/
function newSession({ name, cwd, argv }) {
execImpl(["new-session", "-d", "-s", name, "-c", cwd, "--", ...argv]);
}
/** Idempotent — a session that's already gone is not an error. */
function killSession(name) {
try {
execImpl(["kill-session", "-t", name]);
} catch {
/* already gone */
}
}
/** Session names starting with `prefix`. Empty array if tmux has no server running at all. */
function listSessions(prefix) {
let out;
try {
out = execImpl(["list-sessions", "-F", "#{session_name}"]);
} catch {
return [];
}
return out
.split("\n")
.map((s) => s.trim())
.filter((s) => s && s.startsWith(prefix));
}
function isTmuxAvailable() {
try {
execImpl(["-V"]);
return true;
} catch {
return false;
}
}
module.exports = {
hasSession,
newSession,
killSession,
listSessions,
isTmuxAvailable,
__setExecImpl,
__reset,
};
+21 -1
View File
@@ -19,13 +19,33 @@ const DEFAULT_ROOT = path.join(__dirname, "..", "..");
// never make the update checker report commits from somebody else's repo. // never make the update checker report commits from somebody else's repo.
const REMOTE_PRIORITY = ["origin"]; const REMOTE_PRIORITY = ["origin"];
// Scrub git hook environment variables (GIT_DIR, GIT_INDEX_FILE, etc.) that
// leak from a parent git hook process — e.g. this repo's own pre-commit
// hook, which runs `npm run test:server` and therefore this module too.
// Without this, every git call below silently targets the OUTER repo (the
// hook's) instead of `cwd`, since GIT_DIR takes precedence over cwd-based
// discovery. Same scrub `server/lib/worktree.js` already applies.
const GIT_ENV = { ...process.env };
delete GIT_ENV.GIT_DIR;
delete GIT_ENV.GIT_WORK_TREE;
delete GIT_ENV.GIT_INDEX_FILE;
delete GIT_ENV.GIT_COMMON_DIR;
delete GIT_ENV.GIT_OBJECT_DIRECTORY;
delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete GIT_ENV.GIT_PREFIX;
delete GIT_ENV.GIT_NAMESPACE;
delete GIT_ENV.GIT_CONFIG_PARAMETERS;
for (const name of Object.keys(GIT_ENV)) {
if (/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+|GLOBAL|SYSTEM)$/.test(name)) delete GIT_ENV[name];
}
function execGit(cwd, args, opts = {}) { function execGit(cwd, args, opts = {}) {
const timeout = opts.timeout ?? 120_000; const timeout = opts.timeout ?? 120_000;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile( execFile(
"git", "git",
args, args,
{ cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8" }, { cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8", env: GIT_ENV },
(err, stdout) => { (err, stdout) => {
if (err) reject(err); if (err) reject(err);
else resolve(String(stdout).trim()); else resolve(String(stdout).trim());
+35 -53
View File
@@ -18,7 +18,7 @@ const { listPipelines, getPipeline, nodeStates, progressPct } = require("../lib/
const laneFeatures = require("../lib/lane-features"); const laneFeatures = require("../lib/lane-features");
const proofLib = require("../lib/proof"); const proofLib = require("../lib/proof");
const { broadcast } = require("../websocket"); const { broadcast } = require("../websocket");
const runs = require("../lib/run-spawner"); const runs = require("../lib/pty-run");
const { sameOriginGuard } = require("./run"); const { sameOriginGuard } = require("./run");
const { preflight } = require("../lib/lane-preflight"); const { preflight } = require("../lib/lane-preflight");
const { const {
@@ -69,8 +69,28 @@ function lastEventAge(lane) {
return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000)); return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000));
} }
/**
* Self-heals a stale `run_id`: a tmux-backed run has no exit event to push a
* release notification, so liveness is re-checked here, on every read,
* instead the same "computed fact, never stored" principle this repo
* already applies to lane runtime up/down. A lane whose run_id points at a
* tmux session that's gone (the pane's process exited, or it was killed
* outside the dashboard entirely) gets released the next time anything reads
* it, exactly like the old push-based handler did, just pulled instead of
* pushed.
*/
function healRunId(lane) {
if (!lane.run_id) return lane;
const run = runs.getRun(lane.run_id);
if (run && run.status === "running") return lane;
lanesLib.updateLane(lane.id, { run_id: null, status: "idle" });
broadcastLane(lane.id);
return lanesLib.getLane(lane.id);
}
function payload(lane) { function payload(lane) {
return lanesLib.lanePayload(lane, lastEventAge(lane)); const healed = healRunId(lane);
return lanesLib.lanePayload(healed, lastEventAge(healed));
} }
/** A feature row's pipeline view, computed the same way payload() computes /** A feature row's pipeline view, computed the same way payload() computes
@@ -91,23 +111,6 @@ function broadcastLane(id) {
if (lane) broadcast("lane_update", { lane: payload(lane) }); if (lane) broadcast("lane_update", { lane: payload(lane) });
} }
/**
* Release the lane holding a run that has just finished. Registered as a
* callback because the spawner must not require this router back: it is
* already required FROM here, and broadcastLane needs this file's payload().
*
* No lane lock: the read, the guard and the write are one synchronous
* better-sqlite3 sequence with no `await` between them, so nothing can
* interleave. Matching run_id is what keeps a lane that has already moved on to
* a different run untouched.
*/
runs.setRunExitHandler(({ runId }) => {
const lane = lanesLib.listLanes().find((l) => l.run_id === runId);
if (!lane) return;
lanesLib.updateLane(lane.id, { run_id: null, status: "idle" });
broadcastLane(lane.id);
});
router.get("/", (_req, res) => { router.get("/", (_req, res) => {
const lanes = lanesLib.listLanes().map(payload); const lanes = lanesLib.listLanes().map(payload);
res.json({ res.json({
@@ -620,8 +623,6 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
}); });
const ACTIONS = new Set(["start", "stop", "message", "clear", "reset", "remove", "purge"]); const ACTIONS = new Set(["start", "stop", "message", "clear", "reset", "remove", "purge"]);
// The modes the spawner accepts, same as POST /api/run.
const RUN_MODES = new Set(["headless", "conversation"]);
const DESTRUCTIVE_ACTIONS = new Set(["reset", "remove", "purge"]); const DESTRUCTIVE_ACTIONS = new Set(["reset", "remove", "purge"]);
const RUN_EXIT_POLL_MS = 50; const RUN_EXIT_POLL_MS = 50;
// killRun escalates from SIGTERM to SIGKILL after five seconds. Leave enough // killRun escalates from SIGTERM to SIGKILL after five seconds. Leave enough
@@ -665,7 +666,7 @@ function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
/** Kill a lane run and wait for the child's real `exit` event before touching its cwd. */ /** Kill a lane run and wait for the tmux session to exit before touching its cwd. */
async function stopLaneRun(lane) { async function stopLaneRun(lane) {
if (!lane.run_id) return; if (!lane.run_id) return;
try { try {
@@ -676,7 +677,7 @@ async function stopLaneRun(lane) {
const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS; const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
let run = runs.getRun(lane.run_id); let run = runs.getRun(lane.run_id);
while (run && !run.actualExitedAt) { while (run && run.status !== "gone") {
if (Date.now() >= deadline) { if (Date.now() >= deadline) {
throw lifecycleError( throw lifecycleError(
"ERUNTIMEOUT", "ERUNTIMEOUT",
@@ -975,7 +976,7 @@ router.post("/:id/sync-base", sameOriginGuard, async (req, res) => {
/** /**
* Lane control. Deliberately thin: every action maps onto one existing * Lane control. Deliberately thin: every action maps onto one existing
* run-spawner call. There is no queue, no chaining, no gate evaluation the * lifecycle function. There is no queue, no chaining, no gate evaluation the
* dashboard drives a lane, it does not orchestrate a pipeline. * dashboard drives a lane, it does not orchestrate a pipeline.
*/ */
router.post("/:id/:action", sameOriginGuard, async (req, res) => { router.post("/:id/:action", sameOriginGuard, async (req, res) => {
@@ -1079,17 +1080,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try { try {
switch (action) { switch (action) {
case "start": { case "start": {
// Same two modes POST /api/run accepts. Unlike that route, an unknown
// value is refused rather than silently coerced to a conversation.
if (body.mode != null && !RUN_MODES.has(body.mode)) {
return res.status(400).json({
error: { code: "EBADMODE", message: `mode must be one of: headless, conversation` },
});
}
// Overwriting run_id while its child is alive orphans that child: a later // Overwriting run_id while its child is alive orphans that child: a later
// reset would kill and await only the RECORDED run, then `git clean -fd` // reset would kill and await only the RECORDED run, then `git clean -fd`
// the directory the orphan is still writing into — the exact hazard // the directory the orphan is still writing into. Stop the first run before starting a
// actualExitedAt exists to close. Stop the first run before starting a
// second. The check and the spawn happen under the per-lane lock so that // second. The check and the spawn happen under the per-lane lock so that
// atomicity is guaranteed rather than an accident of this code having no // atomicity is guaranteed rather than an accident of this code having no
// `await` between them — a future edit that adds one must not reopen the // `await` between them — a future edit that adds one must not reopen the
@@ -1101,13 +1094,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
// spawning a run for a lane that no longer exists. // spawning a run for a lane that no longer exists.
if (!current) return { missing: true }; if (!current) return { missing: true };
const live = current.run_id ? runs.getRun(current.run_id) : null; const live = current.run_id ? runs.getRun(current.run_id) : null;
if (live && (live.status === "spawning" || live.status === "running")) { if (live && live.status === "running") {
return { conflict: true }; return { conflict: true };
} }
const handle = runs.spawnRun({ const handle = runs.spawnRun({
mode: body.mode || "conversation",
laneId: current.id, laneId: current.id,
prompt: body.prompt || "", initialPrompt: body.prompt || "",
cwd: current.cwd, cwd: current.cwd,
model: body.model, model: body.model,
permissionMode: body.permissionMode, permissionMode: body.permissionMode,
@@ -1140,23 +1132,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
break; break;
} }
case "message": { case "message": {
if (!lane.run_id) { return res.status(400).json({
return res error: {
.status(409) code: "EUNSUPPORTED",
.json({ error: { code: "ENORUN", message: "lane has no live run" } }); message:
} "sending input to a lane's run is no longer supported via REST — open the lane's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
// Check that the recorded run is actually live (spawning or running). },
// If a run finished recently, its run_id is still recorded but sendInput });
// would throw ENOTRUNNING. Return 409 so the client knows it's not a server error.
const run = runs.getRun(lane.run_id);
if (!run || (run.status !== "spawning" && run.status !== "running")) {
return res
.status(409)
.json({ error: { code: "ENORUN", message: "lane has no live run" } });
}
runs.sendInput(lane.run_id, String(body.text || ""));
lanesLib.updateLane(lane.id, { needs_action: null });
break;
} }
case "clear": case "clear":
lanesLib.clearLane(lane.id); lanesLib.clearLane(lane.id);
+30 -64
View File
@@ -1,9 +1,9 @@
/** /**
* @file run.js * @file run.js
* @description HTTP routes for the dashboard's Run feature. Spawns and * @description HTTP routes for the dashboard's terminal-run feature. Starts,
* supervises `claude` processes (headless one-shot or multi-turn * resumes, kills, and lists tmux-backed `claude` sessions (one per lane),
* conversation), streams structured envelopes to the client over the * streamed to the client over a dedicated WebSocket path (see
* existing WebSocket, and exposes a tiny CRUD-ish surface for run management. * server/websocket.js `/ws-pty/:runId`) rather than this REST surface.
* *
* Security model: * Security model:
* - Local-first dashboard. The dashboard server is expected to bind to * - Local-first dashboard. The dashboard server is expected to bind to
@@ -22,7 +22,8 @@
const { Router } = require("express"); const { Router } = require("express");
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const runs = require("../lib/run-spawner"); const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
const router = Router(); const router = Router();
@@ -96,11 +97,7 @@ function sanitiseCwd(input) {
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]); const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
router.get("/", (_req, res) => { router.get("/", (_req, res) => {
res.json({ res.json({ items: runs.listRuns() });
items: runs.listRuns(),
maxConcurrent: runs.getMaxConcurrent(),
activeCount: runs.liveCount(),
});
}); });
/** /**
@@ -125,12 +122,7 @@ router.get("/history", (req, res) => {
limit: Number.isFinite(limit) ? limit : 50, limit: Number.isFinite(limit) ? limit : 50,
laneId: Number.isFinite(laneId) ? laneId : null, laneId: Number.isFinite(laneId) ? laneId : null,
}); });
// Cross-reference with live handles so the UI can mark which history const liveIds = new Set(runs.listRuns().map((h) => h.id));
// entries are still attached / running.
const liveIds = new Set();
for (const h of runs.listRuns()) {
if (h.id && (h.status === "running" || h.status === "spawning")) liveIds.add(h.id);
}
res.json({ res.json({
items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })), items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })),
}); });
@@ -260,23 +252,15 @@ router.get("/binary", (_req, res) => {
}); });
}); });
router.get("/tmux", (_req, res) => {
res.json({ available: tmux.isTmuxAvailable() });
});
router.post("/", (req, res) => { router.post("/", (req, res) => {
const body = req.body || {}; const body = req.body || {};
const prompt = typeof body.prompt === "string" ? body.prompt : ""; const laneId = Number.parseInt(String(body.laneId ?? ""), 10);
const mode = body.mode === "headless" ? "headless" : "conversation"; if (!Number.isInteger(laneId)) {
const model = typeof body.model === "string" && body.model ? body.model : null; return res.status(400).json({ error: { code: "EBADLANE", message: "laneId is required" } });
const resumeSessionId =
typeof body.resumeSessionId === "string" && body.resumeSessionId ? body.resumeSessionId : null;
const effort = typeof body.effort === "string" && body.effort ? body.effort : null;
const permissionMode =
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
? body.permissionMode
: "acceptEdits";
// Resuming a conversation can spawn with an empty prompt — claude waits
// on stdin until the user types a follow-up. Headless and fresh
// conversation runs still need a prompt to do anything.
if (!prompt.trim() && !(mode === "conversation" && resumeSessionId)) {
return res.status(400).json({ error: { code: "EBADPROMPT", message: "prompt is required" } });
} }
let cwd; let cwd;
try { try {
@@ -286,22 +270,22 @@ router.post("/", (req, res) => {
} }
try { try {
const handle = runs.spawnRun({ const handle = runs.spawnRun({
prompt, laneId,
mode,
cwd, cwd,
model, model: typeof body.model === "string" && body.model ? body.model : null,
permissionMode, permissionMode:
resumeSessionId, typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
effort, ? body.permissionMode
: "acceptEdits",
effort: typeof body.effort === "string" && body.effort ? body.effort : null,
resumeSessionId:
typeof body.resumeSessionId === "string" && body.resumeSessionId
? body.resumeSessionId
: null,
initialPrompt: typeof body.initialPrompt === "string" ? body.initialPrompt : "",
}); });
return res.status(201).json(runs.getRun(handle.id)); return res.status(201).json(handle);
} catch (err) { } catch (err) {
if (err.code === "ECONCURRENCY") {
return res.status(429).json({
error: { code: err.code, message: err.message },
running: err.running || [],
});
}
if (err.code && err.code.startsWith("E")) { if (err.code && err.code.startsWith("E")) {
return res.status(400).json({ error: { code: err.code, message: err.message } }); return res.status(400).json({ error: { code: err.code, message: err.message } });
} }
@@ -309,27 +293,9 @@ router.post("/", (req, res) => {
} }
}); });
router.post("/:id/message", (req, res) => {
const body = req.body || {};
const text = typeof body.text === "string" ? body.text : "";
if (!text) {
return res.status(400).json({ error: { code: "EBADINPUT", message: "text is required" } });
}
try {
const result = runs.sendInput(req.params.id, text);
return res.json(result);
} catch (err) {
const status = err.code === "ENOTFOUND" ? 404 : 400;
return res.status(status).json({ error: { code: err.code, message: err.message } });
}
});
router.get("/:id", (req, res) => { router.get("/:id", (req, res) => {
// ?envelopes=1 includes the in-memory envelope history so the UI can const handle = runs.getRun(req.params.id);
// re-attach to an active run started elsewhere and see what it missed. if (!handle || handle.status === "gone") {
const includeEnvelopes = req.query.envelopes === "1";
const handle = runs.getRun(req.params.id, { includeEnvelopes });
if (!handle) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } }); return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
} }
return res.json(handle); return res.json(handle);
+98 -15
View File
@@ -9,11 +9,15 @@ const { isHostAllowed, isWebSocketAuthorized } = require("./lib/security");
let wss = null; let wss = null;
function initWebSocket(server) { function initWebSocket(server) {
// Express middleware doesn't run on WS upgrades, so enforce the same Host // `noServer: true` + a manual, path-checked `server.on("upgrade", ...)`
// allowlist (anti DNS-rebinding) and optional token here (GHSA-gr74-4xfh-6jw9). // rather than the `{server, path}` shorthand: that shorthand's own
// internal upgrade listener calls `handleUpgrade` for EVERY upgrade on the
// shared http.Server (path filtering happens inside `handleUpgrade`,
// which `abortHandshake`s with 400 on a mismatch) — so it was answering,
// and killing, `/ws-pty/*` upgrades before the PTY server's own listener
// (registered below by `initPtyWebSocket`) ever got a chance to run.
wss = new WebSocketServer({ wss = new WebSocketServer({
server, noServer: true,
path: "/ws",
maxPayload: 64 * 1024, maxPayload: 64 * 1024,
verifyClient(info, done) { verifyClient(info, done) {
if (!isHostAllowed(info.req.headers.host)) return done(false, 403, "host not allowed"); if (!isHostAllowed(info.req.headers.host)) return done(false, 403, "host not allowed");
@@ -22,6 +26,14 @@ function initWebSocket(server) {
}, },
}); });
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname !== "/ws") return; // not ours — `/ws-pty/*` handles its own path.
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
wss.on("connection", (ws) => { wss.on("connection", (ws) => {
ws.isAlive = true; ws.isAlive = true;
ws.on("pong", () => { ws.on("pong", () => {
@@ -59,6 +71,61 @@ function initWebSocket(server) {
return wss; return wss;
} }
let ptyWss = null;
const PTY_PATH_RE = /^\/ws-pty\/(ccam-lane-\d+)$/;
/**
* Second WebSocket server, dedicated to the terminal-run PTY transport
* (`/ws-pty/:runId`). Kept separate from the `/ws` JSON-broadcast path so
* raw binary PTY frames never have to coexist with the typed
* `{type, data, timestamp}` envelope the rest of the app relies on. Reuses
* the exact same auth guard as `/ws`.
*
* `runId` is a path SEGMENT, not a fixed string, so this can't use the `ws`
* library's `{server, path}` shorthand (that option only matches an exact
* string). Instead this server is created with `noServer: true` and the
* upgrade is handled manually the same `server.on("upgrade", ...)` pattern
* `ws` itself uses internally, just filtered to `/ws-pty/*` first so `/ws`'s
* own upgrade handling (already registered by `initWebSocket`) is untouched.
*/
function initPtyWebSocket(server) {
const { attach } = require("./lib/pty-attach");
ptyWss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
ptyWss.on("connection", (ws, runId) => {
try {
attach(ws, runId, { cols: 80, rows: 24 });
} catch (err) {
try {
ws.close(1008, err.message);
} catch {
/* ignore */
}
}
});
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url, "http://localhost");
const match = url.pathname.match(PTY_PATH_RE);
if (!match) return; // not ours — the `/ws` WebSocketServer (registered
// by initWebSocket, also attached to this same http.Server) handles its
// own path independently and ignores upgrades it doesn't match too.
if (!isHostAllowed(req.headers.host)) {
socket.destroy();
return;
}
if (!isWebSocketAuthorized(req)) {
socket.destroy();
return;
}
ptyWss.handleUpgrade(req, socket, head, (ws) => {
ptyWss.emit("connection", ws, match[1]);
});
});
return ptyWss;
}
function broadcast(type, data) { function broadcast(type, data) {
if (!wss) return; if (!wss) return;
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() }); const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
@@ -90,20 +157,36 @@ function getConnectionCount() {
* clients first lets the HTTP server drain and close promptly. * clients first lets the HTTP server drain and close promptly.
*/ */
function closeWebSocket() { function closeWebSocket() {
if (!wss) return; if (wss) {
wss.clients.forEach((client) => { wss.clients.forEach((client) => {
try {
client.terminate();
} catch {
/* already gone */
}
});
try { try {
client.terminate(); wss.close();
} catch { } catch {
/* already gone */ /* ignore */
} }
}); wss = null;
try { }
wss.close(); if (ptyWss) {
} catch { ptyWss.clients.forEach((client) => {
/* ignore */ try {
client.terminate();
} catch {
/* already gone */
}
});
try {
ptyWss.close();
} catch {
/* ignore */
}
ptyWss = null;
} }
wss = null;
} }
module.exports = { initWebSocket, broadcast, getConnectionCount, closeWebSocket }; module.exports = { initWebSocket, initPtyWebSocket, broadcast, getConnectionCount, closeWebSocket };