Compare commits
45 Commits
7357070fb9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c29504c75 | |||
| 39572aa04c | |||
| 3ae0d00b0c | |||
| a2b5fa4669 | |||
| bab19e2f36 | |||
| 6f22aed47c | |||
| d542fbbf4b | |||
| 764dc6a7b5 | |||
| 06817b7901 | |||
| 14f116bf00 | |||
| 6dda604362 | |||
| 22ce61bcfe | |||
| 8a61a2b359 | |||
| b1d43bf098 | |||
| 11b779479d | |||
| 18a1ecb6f9 | |||
| fa416b5e6b | |||
| 0f15800b23 | |||
| 43f29ee904 | |||
| 18a42873b2 | |||
| b0bfc66d65 | |||
| 78fb82b257 | |||
| 7e2bb6225f | |||
| 774ee48f19 | |||
| bcd1259ed2 | |||
| 251a18dc39 | |||
| d581705eb0 | |||
| cb8800fa31 | |||
| 9c3331c843 | |||
| 31af7aefbf | |||
| 6709f9a192 | |||
| b951f64321 | |||
| 2f39f4ec98 | |||
| f1e7d4245a | |||
| 82bf803c2e | |||
| 24f13911fe | |||
| 9b8d9bbe39 | |||
| 872c698132 | |||
| 1bc237198c | |||
| 56744b360d | |||
| 1dd18fe98c | |||
| d96d552428 | |||
| 00f6338d4c | |||
| dfea1a99d6 | |||
| f4dc6a0730 |
+5
-4
@@ -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) |
|
||||||
|
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+17
@@ -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",
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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í Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <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í Vĩ <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,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" />
|
||||||
|
|||||||
@@ -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í Vĩ <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í Vĩ <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í Vĩ <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í Vĩ <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í Vĩ <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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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í Vĩ <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 };
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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í Vĩ <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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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í Vĩ <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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+388
-934
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
Generated
+93
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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í Vĩ <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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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í Vĩ <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
@@ -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í Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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í Vĩ <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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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í Vĩ <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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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í Vĩ <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 };
|
||||||
@@ -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í Vĩ <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,
|
||||||
|
};
|
||||||
@@ -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í Vĩ <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,
|
|
||||||
};
|
|
||||||
@@ -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í Vĩ <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 };
|
|
||||||
@@ -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í Vĩ <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,
|
||||||
|
};
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user