Compare commits
49 Commits
7357070fb9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ab6d6410d5 | |||
| 37adf983e3 | |||
| c25008ab19 | |||
| 174c650624 | |||
| 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()` |
|
||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
||||
| `lib/stream-json-parser.js` | Newline-delimited JSON line buffer for parsing `claude --output-format stream-json` output. Reassembles arbitrarily chunked stdout into discrete envelopes. Robust: malformed lines are reported via an `onError` callback but never throw |
|
||||
| `lib/run-spawner.js` | Spawns and supervises `claude` subprocesses for the Run page. Two modes: **headless** (`-p "<prompt>"` in argv, stdin closed, exits after one turn) and **conversation** (`--input-format stream-json`, prompt + follow-ups piped over stdin, multi-turn). Conversation mode also supports `resumeSessionId` → `--resume <id>`; an empty `prompt` is permitted in this case (the spawner skips the initial stdin write so `claude` idles on the resumed transcript until the user POSTs a follow-up via `/run/:id/message`). The argv builder also passes through an optional `effort` (`low`/`medium`/`high`) → `--effort`. Output is always `--output-format stream-json --verbose --include-partial-messages` so the parser yields character-level deltas (`stream_event` envelopes) the UI can render token-by-token; each envelope is broadcast as `run_stream` over the existing WebSocket. Status transitions broadcast as `run_status`. A failed spawn records an actual-exit timestamp too: no child started, so lane teardown can safely proceed instead of waiting for a nonexistent `exit` event. SIGTERM escalation checks that timestamp rather than Node's delivery-acknowledgement `child.killed`, so a child that ignores SIGTERM still receives SIGKILL after five seconds. Concurrency is effectively uncapped (default ceiling 10000 — matches the terminal TUI which has no cap; the cap is sanity-only to prevent fork-bomb footguns from a buggy client; override with `RUN_MAX_CONCURRENT`, NaN-safe). Per-handle bounded envelope log (cap 500) lets late-attaching clients replay history via `?envelopes=1`. The Run page additionally reconciles this in-memory log against the session's on-disk JSONL transcript on every attach (incl. clicking Resume / View on a row) — when the transcript has more user/assistant messages than the spawner saw (e.g., a resumed run whose prior history never traversed stdout), it supersedes; otherwise the spawner's log wins (it has stream_event deltas the transcript doesn't carry until each turn finalizes). This is what makes leaving a resumed run and coming back show the same chat the user saw initially. Completed handles reaped after 5 min; full transcripts persist via the normal hook ingestion pipeline because every spawned `claude` fires hooks like any other CLI session |
|
||||
| `routes/run.js` | HTTP surface for the Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). cwd sanitization: must be absolute and exist as a directory. `GET /` lists handles + concurrency state. `GET /binary` probes whether `claude` is on `PATH`. `GET /cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /files?cwd=&q=` powers the Run page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /` spawns (accepts `effort` in body). `POST /:id/message` sends a follow-up turn. `GET /:id` returns the handle; `?envelopes=1` includes the in-memory envelope log for re-attach. `DELETE /:id` SIGTERMs (escalates to SIGKILL after 5 s) |
|
||||
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `ccam-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
|
||||
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `ccam lanes shell` CLI, etc.), all synced live |
|
||||
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.ccam/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. |
|
||||
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
|
||||
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and CCAM, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
|
||||
@@ -623,7 +624,7 @@ graph LR
|
||||
| `/analytics` | Analytics | `GET /api/analytics` |
|
||||
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
||||
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
|
||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Spawns `claude` subprocesses with chat-style streaming UI, tied to lanes: the UI opens on a `cwd`, calls `POST /api/lanes/ensure` when no lane owns it yet, then starts runs through `POST /api/lanes/:id/start` (which accepts `mode: "conversation" \| "headless"` and `effort: "low" \| "medium" \| "high"`). **A finished run releases its lane** (clears `run_id`, returns status to `idle`). Displays a horizontal lane strip at the top, the selected lane's pipeline map, and run configuration/console/history below. `GET /api/run/{binary,cwds,files}` for pre-flight + `@`-file autocomplete; `POST /api/run/:id/message` for follow-up turns; `DELETE /api/run/:id` to stop (lane-tied runs go through `POST /api/lanes/:id/stop` instead). `GET /api/run/history?laneId=<n>` lists only that lane's runs. WS messages: `run_stream` (includes `stream_event` deltas), `run_status`, `run_input_ack`, `lane_update`. Streaming pipeline: each WS envelope is dispatched through `flushSync` so React 18 doesn't batch bursts into a single render; a `useTypewriterEnvelopes` hook drips text/thinking deltas via `requestAnimationFrame` so even short replies type in; the merge code preserves `_streaming` and the delta-accumulated content array when claude's canonical `assistant` envelope arrives mid-stream so thinking blocks aren't dropped. Tier 1 TUI parity: collapsible-to-pill limitations banner, slash + `@`-file autocomplete (dropdowns open upward, slash matching uses tiered scoring), live token / context-window meter, status header. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title |
|
||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title |
|
||||
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
|
||||
| `/*` | NotFound | None (static 404 page) |
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ RUN npm run build
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Runs Claude Code sessions inside a named tmux session per lane so both the
|
||||
# dashboard (via node-pty attach) and a real terminal can share one live pane.
|
||||
RUN apk add --no-cache tmux
|
||||
|
||||
COPY --from=server-deps /app/node_modules ./node_modules/
|
||||
COPY package.json ./
|
||||
COPY server/ ./server/
|
||||
|
||||
@@ -6,6 +6,8 @@ tool call to an Express + SQLite server, a React UI updates over WebSocket, and
|
||||
|
||||
Internal build — all rights reserved.
|
||||
|
||||
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
||||
|
||||
## What it does
|
||||
|
||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
||||
@@ -15,8 +17,10 @@ Internal build — all rights reserved.
|
||||
- **Stage detection.** The stage is inferred from the tool stream, so a session
|
||||
that never calls `ccam stage` still shows progress — rendered dashed amber and
|
||||
never as done, because an inference is not evidence.
|
||||
- **Run Claude from the browser.** Spawn a session in a lane's directory, stream
|
||||
its output, send follow-ups, resume any past session.
|
||||
- **Run Claude from the browser.** A real terminal (tmux + a real PTY,
|
||||
rendered with xterm.js) attached to a lane's directory — the exact TUI you'd
|
||||
see locally, fully interactive, resumable, and attachable from a real
|
||||
terminal too via `ccam lanes shell`.
|
||||
- **Analytics, alerts, Kanban and a workflow view**, plus an MCP server and a CLI.
|
||||
|
||||
## Requirements
|
||||
@@ -75,6 +79,7 @@ ccam status # is the dashboard up
|
||||
ccam start # start it in the background and wait for healthy
|
||||
ccam sessions # recent sessions
|
||||
ccam lanes # lanes with stage and progress
|
||||
ccam lanes pipeline # this lane's pipeline template, or switch it
|
||||
ccam stage <name> # declare the current lane's stage
|
||||
ccam tail # live event feed
|
||||
```
|
||||
@@ -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,
|
||||
and an inferred node never renders as done.
|
||||
|
||||
A lane's pipeline template can be switched after creation — `ccam lanes
|
||||
pipeline <template>` from the terminal, or the pipeline-template picker next to
|
||||
the lane's title in the Workspace detail panel. Both re-resolve the lane's
|
||||
current declared stage against the new template's nodes and warn if it no
|
||||
longer matches one.
|
||||
|
||||
A lane can also run **its own application stack**, isolated per lane, when its
|
||||
repository declares a profile at `<repo>/.ccam/profile/` — a `profile.env` of
|
||||
declarations plus shell hooks the dashboard calls. Each lane gets a slot, and its
|
||||
|
||||
+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) {
|
||||
const slug = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!slug) {
|
||||
@@ -2484,6 +2509,11 @@ const COMMAND_GROUPS = [
|
||||
"[<template-id>] [<id>]",
|
||||
"Show, or switch, which pipeline template a lane renders against",
|
||||
],
|
||||
[
|
||||
"lanes shell",
|
||||
"[<id>]",
|
||||
"Attach a real terminal to the exact tmux session the dashboard uses for a lane's run",
|
||||
],
|
||||
[
|
||||
"lanes reset|remove|purge",
|
||||
"<id> [--force] [--keep-db] --yes",
|
||||
@@ -3362,6 +3392,9 @@ async function runCommand(argv) {
|
||||
if (rest[0] === "pipeline") {
|
||||
return cmdLanesPipeline(rest.slice(1));
|
||||
}
|
||||
if (rest[0] === "shell") {
|
||||
return cmdLanesShell(rest.slice(1));
|
||||
}
|
||||
if (rest[0] === "gc") {
|
||||
return cmdLanesGc(rest.slice(1));
|
||||
}
|
||||
|
||||
Generated
+17
@@ -10,6 +10,8 @@
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"i18next": "^26.0.8",
|
||||
@@ -2100,6 +2102,21 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"i18next": "^26.0.8",
|
||||
|
||||
@@ -167,26 +167,11 @@ describe("reduceTabby counts and pulses", () => {
|
||||
expect(ok.state.worriedUntil).toBe(0);
|
||||
});
|
||||
|
||||
it("run_status completed exit 0 is happy, nonzero/error/killed is worried", () => {
|
||||
const good = reduceTabby(
|
||||
initialTabbyState(T0),
|
||||
runStatusMsg({ status: "completed", exitCode: 0 }),
|
||||
T0
|
||||
);
|
||||
expect(good.pulse).toBe("run_done");
|
||||
expect(deriveMood(good.state, T0)).toBe("happy");
|
||||
const bad = reduceTabby(
|
||||
initialTabbyState(T0),
|
||||
runStatusMsg({ status: "completed", exitCode: 1 }),
|
||||
T0
|
||||
);
|
||||
expect(bad.pulse).toBe("error");
|
||||
const err = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "error" }), T0);
|
||||
expect(err.pulse).toBe("error");
|
||||
const killed = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "killed" }), T0);
|
||||
expect(killed.pulse).toBe("error");
|
||||
it("run_status updates activity timestamp only (no exit code available)", () => {
|
||||
const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0);
|
||||
expect(running.pulse).toBe(null);
|
||||
const gone = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "gone" }), T0);
|
||||
expect(gone.pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("any handled message refreshes lastActivityAt", () => {
|
||||
|
||||
@@ -340,25 +340,7 @@ export function reduceTabby(
|
||||
case "run_status": {
|
||||
const r = msg.data as RunStatusPayload;
|
||||
if (!r) return { state, pulse: null };
|
||||
// A run that finished cleanly (exit 0, or no exit code reported) → happy.
|
||||
if (r.status === "completed" && (r.exitCode == null || r.exitCode === 0)) {
|
||||
return {
|
||||
state: { ...state, happyUntil: now + HAPPY_MS, lastActivityAt: now },
|
||||
pulse: "run_done",
|
||||
};
|
||||
}
|
||||
// Errored, killed, or completed with a nonzero exit code → worried.
|
||||
if (
|
||||
r.status === "error" ||
|
||||
r.status === "killed" ||
|
||||
(r.status === "completed" && r.exitCode != null && r.exitCode !== 0)
|
||||
) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
};
|
||||
}
|
||||
// spawning / running → activity only.
|
||||
// running / gone → activity only (no exit code to distinguish success/failure).
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @file The compact lane tile used in the Workspace carousel. It carries only
|
||||
* what you need to pick a lane — which lane, is it alive, what stage, how far —
|
||||
* because the full card, its controls and its working-copy facts live in the
|
||||
* detail panel below. Keeping the tile small is what lets a dozen lanes stay
|
||||
* scannable in one horizontal row.
|
||||
* @file The compact lane tile used in the Workspace's vertical lane list. It
|
||||
* carries only what you need to pick a lane — which lane, is it alive, what
|
||||
* stage, how far — because the full card, its controls and its working-copy
|
||||
* facts live in the detail panel beside it. Keeping the tile small and full
|
||||
* width is what lets many lanes stay scannable in one scrolling column.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function LaneStripCard({
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
title={lane.cwd}
|
||||
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||
className={`w-full shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* @file LaneConsolePane.tsx
|
||||
* @description One lane's run console: the RunSetup ↔ TerminalView switcher,
|
||||
* moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of
|
||||
* these side by side (split terminal view). Owns its own prompt/cwd/model/
|
||||
* permissionMode/effort/resumeSession/handle/busy/runHistory state — nothing
|
||||
* is shared between panes. `lanes`, `binaryStatus`, `cwdSuggestions`,
|
||||
* `activeRuns`, and `externalSessions` are supplied as props because they are global, not
|
||||
* lane-specific, and fetching them per pane would mean N redundant identical
|
||||
* requests for an N-pane layout.
|
||||
*
|
||||
* That state is bound to the lane the pane currently shows: switching `laneId`
|
||||
* swaps the whole pane over to the new lane — its cwd, its history, and its
|
||||
* live tmux session — instead of leaving the previous lane's terminal on
|
||||
* screen under a new lane's header.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, AlertCircle } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
DashboardRunHistoryItem,
|
||||
EffortLevel,
|
||||
PermissionMode,
|
||||
RunHandle,
|
||||
RunListResponse,
|
||||
RunStartArgs,
|
||||
} from "../../lib/api";
|
||||
import type { Session, Lane } from "../../lib/types";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { RunSetup } from "./RunSetup";
|
||||
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
laneId: number | null;
|
||||
showLaneSelector: boolean;
|
||||
onLaneIdChange: (id: number) => void;
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
binaryStatus: { found: boolean; path: string | null } | null;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
activeRuns: RunListResponse | null;
|
||||
/** Active Claude Code sessions started outside the dashboard, listed in the
|
||||
* active-runs switcher alongside dashboard runs. */
|
||||
externalSessions?: Session[];
|
||||
wsConnected: boolean;
|
||||
defaultCwd?: string;
|
||||
onHasActiveRunChange?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
export function LaneConsolePane({
|
||||
lanes,
|
||||
laneId,
|
||||
showLaneSelector,
|
||||
onLaneIdChange,
|
||||
onLaneCreated,
|
||||
binaryStatus,
|
||||
cwdSuggestions,
|
||||
activeRuns,
|
||||
externalSessions,
|
||||
wsConnected,
|
||||
defaultCwd,
|
||||
onHasActiveRunChange,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const { t: tLanes } = useTranslation("lanes");
|
||||
const { t: tCommon } = useTranslation("common");
|
||||
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>("acceptEdits");
|
||||
const [effort, setEffort] = useState<EffortLevel>("");
|
||||
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
|
||||
const [resumeSession, setResumeSession] = useState<Session | null>(null);
|
||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
|
||||
|
||||
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
}, [handle, onHasActiveRunChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLane && defaultCwd && cwd === "") {
|
||||
setCwd(defaultCwd);
|
||||
}
|
||||
}, [defaultCwd, currentLane, cwd]);
|
||||
|
||||
const refreshList = useCallback(() => {
|
||||
if (laneId !== null) {
|
||||
api.run
|
||||
.history(50, { laneId })
|
||||
.then((r) => setRunHistory(r.items))
|
||||
.catch(() => undefined);
|
||||
} else {
|
||||
api.run
|
||||
.history(50)
|
||||
.then((r) => setRunHistory(r.items))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}, [laneId]);
|
||||
|
||||
// `lanes` and `activeRuns` are re-fetched every few seconds by the page, so
|
||||
// reading them through a ref keeps the lane-switch effect below off their
|
||||
// identity — a background poll must not wipe a half-typed prompt.
|
||||
const latest = useRef({ lanes, activeRuns, defaultCwd });
|
||||
latest.current = { lanes, activeRuns, defaultCwd };
|
||||
|
||||
// A pane's prompt, cwd, history and terminal all belong to the lane it
|
||||
// shows, so switching lanes has to swap every one of them. Re-attach right
|
||||
// away when the new lane already has a live run: each lane sticks to its own
|
||||
// tmux session, and the switch should land on that session rather than on an
|
||||
// empty setup form the user then has to Start out of.
|
||||
useEffect(() => {
|
||||
const { activeRuns: runs } = latest.current;
|
||||
setPrompt("");
|
||||
setResumeSession(null);
|
||||
setError(null);
|
||||
setBusy(null);
|
||||
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
|
||||
refreshList();
|
||||
}, [laneId, refreshList]);
|
||||
|
||||
// The cwd tracks the lane's own folder separately, keyed on the resolved
|
||||
// path rather than on `laneId` alone: the pane can mount before the lane
|
||||
// list has loaded (split view restores its pane lanes from localStorage),
|
||||
// and `laneId` never changes afterwards, so a laneId-only effect would leave
|
||||
// the console pointing at the default directory. RunSetup submits this
|
||||
// string verbatim, so a stale one starts the run in the wrong folder.
|
||||
const laneCwd = currentLane?.cwd ?? null;
|
||||
useEffect(() => {
|
||||
if (laneCwd) setCwd(laneCwd);
|
||||
else if (laneId === null) setCwd(latest.current.defaultCwd ?? "");
|
||||
}, [laneId, laneCwd]);
|
||||
|
||||
const attachToRun = useCallback(
|
||||
async (id: string) => {
|
||||
if (busy) return;
|
||||
setBusy("attach");
|
||||
setError(null);
|
||||
try {
|
||||
const fetched = await api.run.get(id);
|
||||
setHandle(fetched);
|
||||
} catch (err: unknown) {
|
||||
const m = err instanceof Error ? err.message : "unknown";
|
||||
setError(t("errors.attachFailed", { message: m }));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, t]
|
||||
);
|
||||
|
||||
const onStartFromSetup = useCallback(
|
||||
async (args: RunStartArgs) => {
|
||||
if (busy) return;
|
||||
setBusy("start");
|
||||
setError(null);
|
||||
try {
|
||||
const effectiveCwd = args.cwd || undefined;
|
||||
|
||||
if (!effectiveCwd) {
|
||||
throw new Error(t("errors.cwdRequired"));
|
||||
}
|
||||
|
||||
// Resolve the lane from the cwd the user actually typed, not from
|
||||
// args.laneId — RunSetup always supplies this pane's laneId (a
|
||||
// required prop), which would otherwise silently start a run in the
|
||||
// wrong lane whenever the user types a cwd different from the one
|
||||
// this pane currently shows.
|
||||
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd);
|
||||
let targetLaneId: number;
|
||||
if (ownedLane) {
|
||||
targetLaneId = ownedLane.id;
|
||||
if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id);
|
||||
} else {
|
||||
try {
|
||||
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
|
||||
targetLaneId = ensureResult.lane.id;
|
||||
onLaneIdChange(ensureResult.lane.id);
|
||||
onLaneCreated(ensureResult.lane);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
t("errors.laneCreateFailed", {
|
||||
message: err instanceof Error ? err.message : "unknown",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
prompt: args.initialPrompt || "",
|
||||
model: args.model || undefined,
|
||||
permissionMode: args.permissionMode,
|
||||
resumeSessionId: args.resumeSessionId,
|
||||
effort: args.effort || undefined,
|
||||
});
|
||||
} catch (laneErr: unknown) {
|
||||
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
|
||||
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
|
||||
const fresh = await api.lanes.list().catch(() => null);
|
||||
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||
if (updatedLane?.run_id) {
|
||||
await attachToRun(updatedLane.run_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw laneErr;
|
||||
}
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
throw new Error(t("errors.noRunIdReturned"));
|
||||
}
|
||||
|
||||
try {
|
||||
const fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||
setHandle(fetched);
|
||||
refreshList();
|
||||
} catch {
|
||||
try {
|
||||
await attachToRun(laneStartResult.lane.run_id);
|
||||
refreshList();
|
||||
} catch (fallbackErr: unknown) {
|
||||
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
|
||||
throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg }));
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const m = err instanceof Error ? err.message : "unknown";
|
||||
setError(t("errors.startFailed", { message: m }));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, t, lanes, laneId, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
|
||||
);
|
||||
|
||||
const onResumeFromHistory = useCallback(
|
||||
async (item: DashboardRunHistoryItem) => {
|
||||
if (!item.session_id) return;
|
||||
if (busy) return;
|
||||
setBusy("start");
|
||||
setError(null);
|
||||
try {
|
||||
let fetched: RunHandle;
|
||||
|
||||
if (item.cwd) {
|
||||
const effectiveCwd = item.cwd;
|
||||
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd)?.id;
|
||||
|
||||
if (!targetLaneId) {
|
||||
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd });
|
||||
targetLaneId = ensureResult.lane.id;
|
||||
onLaneCreated(ensureResult.lane);
|
||||
}
|
||||
|
||||
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
effort: item.effort || undefined,
|
||||
resumeSessionId: item.session_id,
|
||||
});
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
throw new Error("No run_id returned from lane start");
|
||||
}
|
||||
|
||||
fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||
onLaneIdChange(targetLaneId);
|
||||
} else {
|
||||
fetched = await api.run.start({
|
||||
laneId: 0,
|
||||
initialPrompt: "",
|
||||
cwd: undefined,
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
effort: item.effort || undefined,
|
||||
resumeSessionId: item.session_id,
|
||||
});
|
||||
}
|
||||
|
||||
setHandle(fetched);
|
||||
setResumeSession(null);
|
||||
refreshList();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "unknown";
|
||||
setError(t("errors.startFailed", { message: msg }));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
|
||||
);
|
||||
|
||||
const onViewFromHistory = useCallback(
|
||||
(item: DashboardRunHistoryItem) => {
|
||||
if (item.session_id) void onResumeFromHistory(item);
|
||||
},
|
||||
[onResumeFromHistory]
|
||||
);
|
||||
|
||||
const newRun = useCallback(() => {
|
||||
setHandle(null);
|
||||
setPrompt("");
|
||||
setResumeSession(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
if (laneId === null && showLaneSelector) {
|
||||
return (
|
||||
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
<select
|
||||
data-testid="pane-lane-select"
|
||||
aria-label={tLanes("splitView.paneLaneLabel")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
|
||||
value=""
|
||||
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
|
||||
>
|
||||
<option value="">{tLanes("splitView.pickLane")}</option>
|
||||
{lanes.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.title || l.cwd}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-fg-muted">{tLanes("splitView.emptyPane")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
|
||||
{showLaneSelector && (
|
||||
<select
|
||||
data-testid="pane-lane-select"
|
||||
aria-label={tLanes("splitView.paneLaneLabel")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-secondary"
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => e.target.value && onLaneIdChange(Number(e.target.value))}
|
||||
>
|
||||
<option value="">{tLanes("splitView.pickLane")}</option>
|
||||
{lanes.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.title || l.cwd}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<header className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||
<Play className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
{wsConnected ? (
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
||||
{tCommon("live")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
|
||||
{tCommon("offline")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
|
||||
</div>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
currentHandleId={handle?.id || null}
|
||||
onAttach={attachToRun}
|
||||
runHistory={runHistory}
|
||||
externalSessions={externalSessions}
|
||||
onResumeFromHistory={onResumeFromHistory}
|
||||
onViewFromHistory={onViewFromHistory}
|
||||
onRefresh={refreshList}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{binaryStatus && !binaryStatus.found && (
|
||||
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t("binary.missing")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 break-all">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!handle ? (
|
||||
<RunSetup
|
||||
laneId={laneId ?? 0}
|
||||
prompt={prompt}
|
||||
onPromptChange={setPrompt}
|
||||
cwd={cwd}
|
||||
onCwdChange={setCwd}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
model={model}
|
||||
onModelChange={setModel}
|
||||
permissionMode={permissionMode}
|
||||
onPermissionModeChange={setPermissionMode}
|
||||
effort={effort}
|
||||
onEffortChange={setEffort}
|
||||
binaryFound={binaryStatus?.found ?? true}
|
||||
busy={busy === "start"}
|
||||
onStart={onStartFromSetup}
|
||||
activeRuns={activeRuns}
|
||||
laneCwd={currentLane?.cwd}
|
||||
resumeSession={resumeSession}
|
||||
onResumeSessionChange={setResumeSession}
|
||||
runHistory={runHistory}
|
||||
onResumeFromHistory={onResumeFromHistory}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<TerminalView
|
||||
runId={handle!.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
/>
|
||||
<button
|
||||
onClick={newRun}
|
||||
className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors"
|
||||
>
|
||||
{t("actions.newRun")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,16 @@
|
||||
* status / mode chip filters, a free-text search, and the per-row Attach /
|
||||
* Resume / View actions.
|
||||
*
|
||||
* Props only: no API call of its own. The page passes `activeRuns` and
|
||||
* `runHistory` in and gets attach / resume / view / refresh back out through
|
||||
* callbacks; the 2 s refresh ticker the modal runs just calls `onRefresh`.
|
||||
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||
* "Active runs" counts everything actually running. They carry no tmux session
|
||||
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd.
|
||||
*
|
||||
* Props only: no API call of its own. The page passes `activeRuns`,
|
||||
* `runHistory` and `externalSessions` in and gets attach / resume / view /
|
||||
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
|
||||
* just calls `onRefresh`.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -33,8 +40,28 @@ import {
|
||||
RotateCcw,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import type { DashboardRunHistoryItem, RunListResponse, RunMode, RunStatus } from "../../lib/api";
|
||||
import { ModeBadge, StatusPill } from "./RunConsole";
|
||||
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
|
||||
// Minimal StatusPill component (from deleted RunConsole)
|
||||
function StatusPill({
|
||||
status,
|
||||
}: {
|
||||
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
|
||||
}) {
|
||||
const colors: Record<string, string> = {
|
||||
running: "bg-status-success/10 text-status-success border-status-success/30",
|
||||
gone: "bg-surface-3 text-fg-secondary border-border",
|
||||
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
|
||||
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
|
||||
killed: "bg-surface-3 text-fg-secondary border-border",
|
||||
abandoned: "bg-surface-3 text-fg-secondary border-border",
|
||||
};
|
||||
const color = colors[status] || colors.abandoned;
|
||||
return (
|
||||
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${color}`}>{status}</span>
|
||||
);
|
||||
}
|
||||
|
||||
type RunStatusFilter =
|
||||
| "all"
|
||||
@@ -44,19 +71,21 @@ type RunStatusFilter =
|
||||
| "error"
|
||||
| "killed"
|
||||
| "abandoned";
|
||||
type RunModeFilter = "all" | "conversation" | "headless";
|
||||
|
||||
export interface UnifiedRunRow {
|
||||
id: string;
|
||||
sessionId: string | null;
|
||||
mode: RunMode;
|
||||
cwd: string;
|
||||
model: string | null;
|
||||
status: RunStatus;
|
||||
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
|
||||
promptPreview: string;
|
||||
startedAt: number;
|
||||
endedAt: number | null;
|
||||
isLive: boolean;
|
||||
/** Live Claude Code session this dashboard did not spawn — no tmux session to
|
||||
* attach to, so Resume (a fresh `claude --resume` in its cwd) is the only
|
||||
* action. */
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export function ActiveRunsSwitcher({
|
||||
@@ -64,6 +93,7 @@ export function ActiveRunsSwitcher({
|
||||
currentHandleId,
|
||||
onAttach,
|
||||
runHistory,
|
||||
externalSessions = [],
|
||||
onResumeFromHistory,
|
||||
onViewFromHistory,
|
||||
onRefresh,
|
||||
@@ -72,6 +102,9 @@ export function ActiveRunsSwitcher({
|
||||
currentHandleId: string | null;
|
||||
onAttach: (id: string) => void;
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
/** Sessions with `status: "active"` from GET /api/sessions. Remote-source and
|
||||
* cwd-less sessions are ignored — neither can be resumed on this machine. */
|
||||
externalSessions?: Session[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
onRefresh: () => void;
|
||||
@@ -94,37 +127,40 @@ export function ActiveRunsSwitcher({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Merge live in-memory handles + persistent history into one row list.
|
||||
// Live entries dedupe past-history entries with the same id.
|
||||
const rows: UnifiedRunRow[] = useMemo(() => {
|
||||
// Merge live in-memory handles + persistent history + externally started
|
||||
// sessions into one row list. Live entries dedupe past-history entries with
|
||||
// the same id; a session id already covered by a run row is never repeated as
|
||||
// an external row.
|
||||
const { rows, historyItems } = useMemo(() => {
|
||||
const out: UnifiedRunRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
const seenSessions = new Set<string>();
|
||||
if (activeRuns) {
|
||||
for (const r of activeRuns.items) {
|
||||
seen.add(r.id);
|
||||
if (r.sessionId) seenSessions.add(r.sessionId);
|
||||
out.push({
|
||||
id: r.id,
|
||||
sessionId: r.sessionId,
|
||||
mode: r.mode,
|
||||
cwd: r.cwd,
|
||||
cwd: r.cwd || "",
|
||||
model: r.model,
|
||||
status: r.status,
|
||||
promptPreview: r.prompt || "",
|
||||
startedAt: r.startedAt,
|
||||
endedAt: r.endedAt,
|
||||
isLive: r.status === "running" || r.status === "spawning",
|
||||
promptPreview: r.promptPreview || "",
|
||||
startedAt: r.startedAt ? new Date(r.startedAt).getTime() : 0,
|
||||
endedAt: null,
|
||||
isLive: r.status === "running",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const h of runHistory) {
|
||||
if (seen.has(h.id)) continue;
|
||||
seen.add(h.id);
|
||||
if (h.session_id) seenSessions.add(h.session_id);
|
||||
const startedTs = new Date(h.started_at).getTime() || 0;
|
||||
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
|
||||
out.push({
|
||||
id: h.id,
|
||||
sessionId: h.session_id,
|
||||
mode: h.mode,
|
||||
cwd: h.cwd,
|
||||
model: h.model,
|
||||
status: h.status,
|
||||
@@ -134,11 +170,51 @@ export function ActiveRunsSwitcher({
|
||||
isLive: h.isLive,
|
||||
});
|
||||
}
|
||||
// Externally started sessions: shown as live rows, and mirrored as
|
||||
// synthetic history items so the existing resume path (which only reads
|
||||
// session_id / cwd / model) works on them unchanged.
|
||||
const synthetic: DashboardRunHistoryItem[] = [];
|
||||
for (const s of externalSessions) {
|
||||
if (!s.cwd) continue;
|
||||
if (s.source && s.source !== "local") continue;
|
||||
if (seenSessions.has(s.id)) continue;
|
||||
seenSessions.add(s.id);
|
||||
synthetic.push({
|
||||
id: `session:${s.id}`,
|
||||
session_id: s.id,
|
||||
cwd: s.cwd,
|
||||
model: s.model,
|
||||
permission_mode: null,
|
||||
effort: null,
|
||||
resume_session_id: null,
|
||||
prompt_preview: s.name,
|
||||
status: "running",
|
||||
exit_code: null,
|
||||
started_at: s.started_at,
|
||||
ended_at: null,
|
||||
isLive: true,
|
||||
});
|
||||
out.push({
|
||||
id: `session:${s.id}`,
|
||||
sessionId: s.id,
|
||||
cwd: s.cwd,
|
||||
model: s.model,
|
||||
status: "running",
|
||||
promptPreview: s.name || "",
|
||||
startedAt: new Date(s.started_at).getTime() || 0,
|
||||
endedAt: null,
|
||||
isLive: true,
|
||||
external: true,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.startedAt - a.startedAt);
|
||||
return out;
|
||||
}, [activeRuns, runHistory]);
|
||||
return {
|
||||
rows: out,
|
||||
historyItems: synthetic.length ? [...runHistory, ...synthetic] : runHistory,
|
||||
};
|
||||
}, [activeRuns, runHistory, externalSessions]);
|
||||
|
||||
const liveCount = activeRuns?.activeCount ?? 0;
|
||||
const liveCount = rows.filter((r) => r.isLive).length;
|
||||
const totalCount = rows.length;
|
||||
|
||||
return (
|
||||
@@ -181,7 +257,7 @@ export function ActiveRunsSwitcher({
|
||||
setOpen(false);
|
||||
onViewFromHistory(item);
|
||||
}}
|
||||
runHistory={runHistory}
|
||||
runHistory={historyItems}
|
||||
onClose={() => setOpen(false)}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
@@ -211,7 +287,6 @@ export function RunsModal({
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all");
|
||||
const [modeFilter, setModeFilter] = useState<RunModeFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Snappy refresh while the modal is the foreground UI: pull immediately
|
||||
@@ -227,25 +302,22 @@ export function RunsModal({
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const byStatus: Record<string, number> = { all: rows.length };
|
||||
const byMode: Record<string, number> = { all: rows.length };
|
||||
for (const r of rows) {
|
||||
byStatus[r.status] = (byStatus[r.status] || 0) + 1;
|
||||
byMode[r.mode] = (byMode[r.mode] || 0) + 1;
|
||||
}
|
||||
return { byStatus, byMode };
|
||||
return { byStatus };
|
||||
}, [rows]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
if (statusFilter !== "all" && r.status !== statusFilter) return false;
|
||||
if (modeFilter !== "all" && r.mode !== modeFilter) return false;
|
||||
if (!q) return true;
|
||||
const hay =
|
||||
r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || "");
|
||||
return hay.toLowerCase().includes(q);
|
||||
});
|
||||
}, [rows, statusFilter, modeFilter, search]);
|
||||
}, [rows, statusFilter, search]);
|
||||
|
||||
const historyById = useMemo(() => {
|
||||
const m = new Map<string, DashboardRunHistoryItem>();
|
||||
@@ -261,7 +333,6 @@ export function RunsModal({
|
||||
"killed",
|
||||
"abandoned",
|
||||
];
|
||||
const MODES: RunModeFilter[] = ["all", "conversation", "headless"];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -343,16 +414,6 @@ export function RunsModal({
|
||||
}))}
|
||||
onChange={(v) => setStatusFilter(v as RunStatusFilter)}
|
||||
/>
|
||||
<FilterChipGroup
|
||||
label={t("runs.filterMode", "Mode")}
|
||||
value={modeFilter}
|
||||
options={MODES.map((m) => ({
|
||||
value: m,
|
||||
label: m === "all" ? t("runs.allLabel", "All") : t(`mode.${m}`),
|
||||
count: counts.byMode[m] || 0,
|
||||
}))}
|
||||
onChange={(v) => setModeFilter(v as RunModeFilter)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -467,10 +528,11 @@ function UnifiedRunRowView({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const canResume = row.mode === "conversation" && !!row.sessionId && !row.isLive;
|
||||
// Headless runs are single-shot, so resume doesn't apply - but the captured
|
||||
// transcript is still worth viewing. Link to the Session detail page.
|
||||
const canView = row.mode === "headless" && !!row.sessionId && !row.isLive;
|
||||
// Without mode distinction, offer resume for any finished run with a session.
|
||||
// An external session is live but has no attachable tmux session, so Resume
|
||||
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
|
||||
const canResume = !!row.sessionId && (!row.isLive || !!row.external);
|
||||
const canView = !!row.sessionId && !row.isLive;
|
||||
return (
|
||||
<div
|
||||
className={`px-5 py-3 transition-colors ${
|
||||
@@ -479,20 +541,27 @@ function UnifiedRunRowView({
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<StatusPill status={row.status} />
|
||||
<ModeBadge mode={row.mode} />
|
||||
{row.isLive && (
|
||||
<span className="text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
|
||||
{t("runs.liveBadge", "live")}
|
||||
</span>
|
||||
)}
|
||||
{row.external && (
|
||||
<span
|
||||
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
|
||||
title={t("runs.externalHint")}
|
||||
>
|
||||
{t("runs.externalBadge")}
|
||||
</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
|
||||
{t("runs.currentBadge", "current")}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto inline-flex items-center gap-1.5">
|
||||
{row.isLive && !isCurrent && (
|
||||
{row.isLive && !row.external && !isCurrent && (
|
||||
<button
|
||||
onClick={onAttach}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-status-success/40 bg-status-success/10 hover:bg-status-success/20 text-status-success px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||
@@ -504,6 +573,7 @@ function UnifiedRunRowView({
|
||||
{canResume && (
|
||||
<button
|
||||
onClick={onResume}
|
||||
title={row.external ? t("runs.externalHint") : undefined}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-2 py-0.5 text-[10.5px] font-medium transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
* the Run page and the Workspace page can both mount the same panel.
|
||||
*
|
||||
* What lives here:
|
||||
* - `RunSetup` — mode (conversation / headless), fresh-vs-resume source, the
|
||||
* prompt editor, and the cwd / model / permission-mode / effort fields,
|
||||
* plus the concurrency hint and the Start button. Its disabled state is
|
||||
* driven by the `binaryFound` prop, so a missing `claude` binary is a
|
||||
* surfaced state here rather than a probe of its own.
|
||||
* above the panel, with its own localStorage-persisted minimized state.
|
||||
* - `RunSetup` — fresh-vs-resume source, the prompt editor, and the cwd /
|
||||
* model / permission-mode / effort fields, plus the concurrency hint and
|
||||
* the Start button. Its disabled state is driven by the `binaryFound` prop,
|
||||
* so a missing `claude` binary is a surfaced state here rather than a probe
|
||||
* of its own.
|
||||
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
|
||||
* `ModelPicker`, and the small `ModeOption` / `Field` layout helpers.
|
||||
* `ModelPicker`, and the small `Field` layout helper.
|
||||
*
|
||||
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
|
||||
* lifecycle — the page owns `api.run.start` and hands the result back through
|
||||
@@ -45,18 +44,53 @@ import type {
|
||||
RunListResponse,
|
||||
EffortLevel,
|
||||
PermissionMode,
|
||||
RunMode,
|
||||
RunStartArgs,
|
||||
} from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
import { PromptEditor } from "./RunConsole";
|
||||
import type { SlashCommand } from "./RunConsole";
|
||||
|
||||
// Minimal SlashCommand type (from deleted RunConsole)
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
source: "project" | "user" | "plugin" | "builtin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Minimal PromptEditor component (from deleted RunConsole)
|
||||
interface PromptEditorProps {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder: string;
|
||||
rows?: number;
|
||||
slashCommands: SlashCommand[];
|
||||
fileCwd: string;
|
||||
}
|
||||
|
||||
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Limitations banner (above the config card) ────────────────────────
|
||||
|
||||
interface RunSetupProps {
|
||||
mode: RunMode;
|
||||
onModeChange: (m: RunMode) => void;
|
||||
laneId: number;
|
||||
prompt: string;
|
||||
onPromptChange: (s: string) => void;
|
||||
cwd: string;
|
||||
@@ -70,7 +104,7 @@ interface RunSetupProps {
|
||||
onEffortChange: (e: EffortLevel) => void;
|
||||
binaryFound: boolean;
|
||||
busy: boolean;
|
||||
onStart: () => void;
|
||||
onStart: (args: RunStartArgs) => void;
|
||||
activeRuns: RunListResponse | null;
|
||||
resumeSession: Session | null;
|
||||
onResumeSessionChange: (s: Session | null) => void;
|
||||
@@ -78,81 +112,52 @@ interface RunSetupProps {
|
||||
* sessions. Undefined when no lane is selected (the picker then lists
|
||||
* everything, same as before lanes existed). */
|
||||
laneCwd?: string;
|
||||
slashCommands: SlashCommand[];
|
||||
slashCommands?: SlashCommand[];
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
}
|
||||
|
||||
export function RunSetup(props: RunSetupProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const atCap =
|
||||
props.activeRuns != null && props.activeRuns.activeCount >= props.activeRuns.maxConcurrent;
|
||||
const atCap = false; // TODO: re-add when concurrency info is available
|
||||
const isResume = !!props.resumeSession;
|
||||
const [resumePicked, setResumePicked] = useState(isResume);
|
||||
// Keep "resume picked" in sync with the parent. Two cases:
|
||||
// 1. Parent set a resume session (e.g. user clicked Resume in the runs
|
||||
// modal) - flip the radio so the picker is shown and the selection
|
||||
// is visible.
|
||||
// 2. Parent cleared the session and mode flipped to headless - clear
|
||||
// the radio so the form is honest.
|
||||
// Keep "resume picked" in sync with the parent. Parent set a resume session
|
||||
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
|
||||
// is shown and the selection is visible.
|
||||
useEffect(() => {
|
||||
if (isResume && !resumePicked) setResumePicked(true);
|
||||
else if (!isResume && resumePicked && props.mode === "headless") setResumePicked(false);
|
||||
}, [isResume, resumePicked, props.mode]);
|
||||
}, [isResume, resumePicked]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-1">
|
||||
{/* Mode and source on one line. Both are two-way choices made once at
|
||||
spawn time, so a segmented row carries them; the longer explanations
|
||||
live in each button's title rather than in a paragraph. */}
|
||||
{/* Fresh vs resume source picker */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={props.mode === "conversation"}
|
||||
label={t("mode.conversation")}
|
||||
title={t("mode.conversationHint")}
|
||||
onClick={() => props.onModeChange("conversation")}
|
||||
/>
|
||||
<Seg
|
||||
active={props.mode === "headless"}
|
||||
label={t("mode.headless")}
|
||||
title={`${t("mode.headlessHint")} — ${t("hint.headlessExplain")}`}
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
title={t("resume.freshHint")}
|
||||
onClick={() => {
|
||||
props.onModeChange("headless");
|
||||
setResumePicked(false);
|
||||
props.onResumeSessionChange(null);
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={resumePicked}
|
||||
label={t("resume.resumeOption")}
|
||||
title={t("resume.resumeHint")}
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{props.mode === "conversation" && (
|
||||
<>
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
title={t("resume.freshHint")}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
{resumePicked && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<SessionPicker
|
||||
selected={props.resumeSession}
|
||||
onSelect={props.onResumeSessionChange}
|
||||
cwd={props.laneCwd}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -164,10 +169,10 @@ export function RunSetup(props: RunSetupProps) {
|
||||
<PromptEditor
|
||||
value={props.prompt}
|
||||
onChange={props.onPromptChange}
|
||||
onSubmit={props.onStart}
|
||||
placeholder={t("fields.promptPlaceholder")}
|
||||
onSubmit={() => handleStart(props)}
|
||||
placeholder={t("fields.promptPlaceholderTerminal")}
|
||||
rows={5}
|
||||
slashCommands={props.slashCommands}
|
||||
slashCommands={props.slashCommands ?? []}
|
||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||
/>
|
||||
<div className="mt-1 text-[10px] text-fg-muted">
|
||||
@@ -234,17 +239,12 @@ export function RunSetup(props: RunSetupProps) {
|
||||
{atCap ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-status-warning">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })}
|
||||
</span>
|
||||
) : props.activeRuns && props.activeRuns.activeCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-fg-secondary">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
|
||||
{t("concurrency.active", { count: props.activeRuns.activeCount })}
|
||||
{t("concurrency.atCap", { max: 0 })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={props.onStart}
|
||||
onClick={() => handleStart(props)}
|
||||
disabled={
|
||||
!props.binaryFound ||
|
||||
!props.prompt.trim() ||
|
||||
@@ -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`
|
||||
* instead of a hint line, which is what keeps the row to one line. */
|
||||
function Seg({
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file TerminalView.tsx
|
||||
* @description Renders one lane's live terminal — a real `xterm.js` instance
|
||||
* attached via WebSocket to the server's `/ws-pty/:runId` path (see
|
||||
* server/lib/pty-attach.js), which is itself a `node-pty`-backed
|
||||
* `tmux attach-session`. Binary WS frames are raw PTY bytes in both
|
||||
* directions; a JSON text frame carries the initial `resize` on mount and
|
||||
* the server's one-shot `exit` notice when the pane process ends.
|
||||
* @author Nguyễn Ngọc Trí 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,197 @@
|
||||
/**
|
||||
* @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("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
||||
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
||||
const run = (id: string, laneId: number) => ({
|
||||
id,
|
||||
laneId,
|
||||
status: "running" as const,
|
||||
cwd: null,
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
const props = {
|
||||
...baseProps(),
|
||||
lanes: [LANE, LANE2],
|
||||
activeRuns: { items: [run("ccam-lane-1", 1), run("ccam-lane-2", 2)] },
|
||||
};
|
||||
|
||||
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-1")
|
||||
);
|
||||
|
||||
rerender(<LaneConsolePane {...props} laneId={2} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "ccam-lane-2")
|
||||
);
|
||||
|
||||
// A lane with no live run falls back to its setup form, not the previous
|
||||
// lane's terminal.
|
||||
rerender(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />);
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("adopts the lane's cwd when the lane list arrives after the pane mounted", async () => {
|
||||
// Split view restores its pane lanes from localStorage, so a pane can
|
||||
// render with a laneId before GET /api/lanes has answered. laneId never
|
||||
// changes afterwards — only the resolved lane does.
|
||||
const props = { ...baseProps(), lanes: [], defaultCwd: "/home/tester" };
|
||||
const { rerender } = render(<LaneConsolePane {...props} />);
|
||||
const cwdInput = screen.getByPlaceholderText(/type to search/i) as HTMLInputElement;
|
||||
expect(cwdInput.value).toBe("/home/tester");
|
||||
|
||||
rerender(<LaneConsolePane {...props} lanes={[LANE]} />);
|
||||
await waitFor(() => expect(cwdInput.value).toBe(LANE.cwd));
|
||||
});
|
||||
|
||||
it("shows a lane dropdown only when showLaneSelector is true", () => {
|
||||
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
|
||||
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||
|
||||
rerender(<LaneConsolePane {...baseProps()} showLaneSelector={false} />);
|
||||
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders RunSetup when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => {
|
||||
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
|
||||
expect(screen.getByTestId("console-body")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
|
||||
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
|
||||
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* @file RunConsole.test.tsx
|
||||
* @description Pins the props-only boundary of `RunConsole` after its move out
|
||||
* of `pages/Run.tsx`: the envelope stream renders from the `envelopes` prop
|
||||
* (no stream subscription of its own), the token meter rolls up usage from
|
||||
* those same envelopes, the prompt editor's `/` autocomplete filters and fills
|
||||
* the prompt through `onFollowUpChange`, and `onSend` / `onStop` fire from the
|
||||
* send and stop controls.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí 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();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
||||
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
||||
import type { Session } from "../../../lib/types";
|
||||
|
||||
const LIVE_ID = "run-live";
|
||||
const PAST_ID = "run-past";
|
||||
@@ -29,12 +30,11 @@ const activeRuns = {
|
||||
{
|
||||
id: LIVE_ID,
|
||||
sessionId: "sess-live",
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/live",
|
||||
model: "claude-opus-5",
|
||||
status: "running",
|
||||
prompt: "the live prompt",
|
||||
startedAt: 3000,
|
||||
startedAt: "2000-01-01T00:50:00Z",
|
||||
promptPreview: "the live prompt",
|
||||
endedAt: null,
|
||||
},
|
||||
],
|
||||
@@ -44,7 +44,6 @@ function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistor
|
||||
return {
|
||||
id: PAST_ID,
|
||||
session_id: "sess-past",
|
||||
mode: "conversation",
|
||||
cwd: "/tmp/past",
|
||||
model: "sonnet",
|
||||
status: "completed",
|
||||
@@ -64,12 +63,26 @@ const PAST = historyItem({});
|
||||
const HEADLESS = historyItem({
|
||||
id: HEADLESS_ID,
|
||||
session_id: "sess-headless",
|
||||
mode: "headless",
|
||||
cwd: "/tmp/headless",
|
||||
prompt_preview: "the headless prompt",
|
||||
started_at: new Date(1000).toISOString(),
|
||||
});
|
||||
|
||||
function externalSession(over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "sess-external",
|
||||
name: "the external prompt",
|
||||
status: "active",
|
||||
cwd: "/tmp/external",
|
||||
model: "claude-opus-5",
|
||||
started_at: new Date(3000).toISOString(),
|
||||
ended_at: null,
|
||||
updated_at: new Date(3000).toISOString(),
|
||||
source: "local",
|
||||
...over,
|
||||
} as unknown as Session;
|
||||
}
|
||||
|
||||
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
||||
const spies = {
|
||||
onAttach: vi.fn(),
|
||||
@@ -95,10 +108,9 @@ function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
|
||||
return {
|
||||
id,
|
||||
sessionId: `sess-${id}`,
|
||||
mode: "conversation",
|
||||
cwd: `/tmp/${id}`,
|
||||
model: "sonnet",
|
||||
status: "completed",
|
||||
status: "abandoned",
|
||||
promptPreview: `prompt of ${id}`,
|
||||
startedAt: 1000,
|
||||
endedAt: 2000,
|
||||
@@ -192,6 +204,44 @@ describe("ActiveRunsSwitcher", () => {
|
||||
expect(screen.queryByText("stale copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("counts and lists a session started outside the dashboard, resumable not attachable", () => {
|
||||
const { spies } = renderSwitcher({
|
||||
activeRuns: null,
|
||||
runHistory: [],
|
||||
externalSessions: [externalSession()],
|
||||
});
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||
expect(screen.getByText("the external prompt")).toBeTruthy();
|
||||
expect(screen.getByText(i18n.t("run:runs.externalBadge"))).toBeTruthy();
|
||||
// No tmux session of ours to attach to — Resume is the only action.
|
||||
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
expect(spies.onResumeFromHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "session:sess-external",
|
||||
session_id: "sess-external",
|
||||
cwd: "/tmp/external",
|
||||
status: "running",
|
||||
isLive: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("skips external sessions already covered by a run, remote ones, and cwd-less ones", () => {
|
||||
renderSwitcher({
|
||||
runHistory: [],
|
||||
externalSessions: [
|
||||
externalSession({ id: "sess-live" }), // same session as the live run
|
||||
externalSession({ id: "sess-remote", source: "remote-1" }),
|
||||
externalSession({ id: "sess-nocwd", cwd: null }),
|
||||
],
|
||||
});
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
||||
expect(screen.getByText("the live prompt")).toBeTruthy();
|
||||
expect(screen.queryByText("the external prompt")).toBeNull();
|
||||
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
|
||||
});
|
||||
|
||||
it("fires attach with the run id of the row that was clicked", () => {
|
||||
const { spies } = renderSwitcher();
|
||||
openModal();
|
||||
@@ -216,39 +266,31 @@ describe("RunsModal", () => {
|
||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||
});
|
||||
|
||||
it("fires resume with the history item behind a finished conversation row", () => {
|
||||
it("fires resume with the history item behind a finished row", () => {
|
||||
const { spies } = renderModal([row(PAST_ID)]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
expect(spies.onResume).toHaveBeenCalledWith(PAST);
|
||||
expect(spies.onView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires view — not resume — for a finished headless row", () => {
|
||||
const { spies } = renderModal([row(HEADLESS_ID, { mode: "headless" })]);
|
||||
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
|
||||
it("fires view for a finished row", () => {
|
||||
const { spies } = renderModal([row(HEADLESS_ID)]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
|
||||
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
|
||||
expect(spies.onResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters by status, by mode and by free text", () => {
|
||||
it("filters by status and by free text", () => {
|
||||
const rows = [
|
||||
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
|
||||
row("b", { status: "error", promptPreview: "bravo" }),
|
||||
row("c", { status: "completed", mode: "headless", promptPreview: "charlie" }),
|
||||
row("b", { status: "killed", promptPreview: "bravo" }),
|
||||
row("c", { status: "abandoned", promptPreview: "charlie" }),
|
||||
];
|
||||
renderModal(rows);
|
||||
|
||||
fireEvent.click(chip(i18n.t("run:status.error")));
|
||||
fireEvent.click(chip(i18n.t("run:status.killed")));
|
||||
expect(screen.getByText("bravo")).toBeTruthy();
|
||||
expect(screen.queryByText("alpha")).toBeNull();
|
||||
|
||||
fireEvent.click(allChip(0));
|
||||
fireEvent.click(chip(i18n.t("run:mode.headless")));
|
||||
expect(screen.getByText("charlie")).toBeTruthy();
|
||||
expect(screen.queryByText("bravo")).toBeNull();
|
||||
|
||||
fireEvent.click(allChip(1));
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(
|
||||
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
|
||||
|
||||
@@ -39,7 +39,6 @@ type Spies = ReturnType<typeof renderSetup>["spies"];
|
||||
|
||||
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
|
||||
const spies = {
|
||||
onModeChange: vi.fn(),
|
||||
onPromptChange: vi.fn(),
|
||||
onCwdChange: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
@@ -52,7 +51,7 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<RunSetup
|
||||
mode="conversation"
|
||||
laneId={1}
|
||||
prompt="do the thing"
|
||||
cwd="/Users/tester"
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
@@ -95,15 +94,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("RunSetup — selections report through callbacks", () => {
|
||||
it("reports the mode from the one-shot / conversation options", () => {
|
||||
const { spies } = renderSetup();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:mode.headless")));
|
||||
expect(spies.onModeChange).toHaveBeenCalledWith("headless");
|
||||
fireEvent.click(screen.getByText(i18n.t("run:mode.conversation")));
|
||||
expect(spies.onModeChange).toHaveBeenLastCalledWith("conversation");
|
||||
onlyCalled(spies, "onModeChange");
|
||||
});
|
||||
|
||||
it("reports the prompt from the editor", () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
|
||||
@@ -171,7 +161,7 @@ describe("RunSetup — missing binary and other blocked states", () => {
|
||||
expect(runButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("still disables Run without a prompt, without a cwd, or at the concurrency cap", () => {
|
||||
it("still disables Run without a prompt or without a cwd", () => {
|
||||
const { unmount } = renderSetup({ prompt: " " });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
unmount();
|
||||
@@ -179,12 +169,6 @@ describe("RunSetup — missing binary and other blocked states", () => {
|
||||
const noCwd = renderSetup({ cwd: "" });
|
||||
expect(runButton().disabled).toBe(true);
|
||||
noCwd.unmount();
|
||||
|
||||
renderSetup({
|
||||
activeRuns: { items: [], activeCount: 2, maxConcurrent: 2 } as never,
|
||||
});
|
||||
expect(runButton().disabled).toBe(true);
|
||||
expect(screen.getByText(i18n.t("run:concurrency.atCap", { max: 2 }))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the Starting… label while busy", () => {
|
||||
@@ -237,7 +221,7 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<RunSetup
|
||||
mode="conversation"
|
||||
laneId={1}
|
||||
prompt="do the thing"
|
||||
cwd="/Users/tester"
|
||||
cwdSuggestions={SUGGESTIONS}
|
||||
@@ -251,7 +235,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
laneCwd="/Users/tester/lane-b"
|
||||
onModeChange={vi.fn()}
|
||||
onPromptChange={vi.fn()}
|
||||
onCwdChange={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file TerminalView.test.tsx
|
||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||
* a WS connection to the right URL, writes incoming binary frames to the
|
||||
* mocked terminal, and forwards typed input as outgoing binary frames.
|
||||
* @author Nguyễn Ngọc Trí 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",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
"laneDetail.show": "Lane details",
|
||||
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
|
||||
"locks.held_one": "{{count}} lock held",
|
||||
"locks.held_other": "{{count}} locks held",
|
||||
@@ -120,6 +122,10 @@
|
||||
"features.archived": "archived",
|
||||
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
|
||||
"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",
|
||||
"title": "Lanes",
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message"
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"permissionMode": "Permission mode",
|
||||
"permissionPlan": "plan (read-only planning)",
|
||||
"prompt": "Prompt",
|
||||
"promptPlaceholder": "Ask Claude anything…"
|
||||
"promptPlaceholder": "Ask Claude anything…",
|
||||
"promptPlaceholderTerminal": "Ask Claude anything…"
|
||||
},
|
||||
"footer": {
|
||||
"cost": "Cost",
|
||||
@@ -115,7 +116,9 @@
|
||||
"runs": {
|
||||
"allSessionsLink": "See all Claude Code sessions →",
|
||||
"attached": "Attached to existing run",
|
||||
"scopeNote": "Only shows runs you started from this dashboard.",
|
||||
"externalBadge": "external",
|
||||
"externalHint": "Started outside the dashboard, so there is no terminal to attach to. Resume opens a new tmux-backed `claude --resume` of this session in its folder.",
|
||||
"scopeNote": "Runs started from this dashboard, plus Claude Code sessions running outside it.",
|
||||
"started": "Started {{when}}",
|
||||
"switcher": "Active runs",
|
||||
"switcherEmpty": "No active runs",
|
||||
|
||||
@@ -90,6 +90,8 @@
|
||||
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
||||
"kind.adopted": "đã nhận",
|
||||
"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}}",
|
||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||
@@ -120,6 +122,10 @@
|
||||
"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.",
|
||||
"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",
|
||||
"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"
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"permissionMode": "Permission mode",
|
||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||
"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": {
|
||||
"cost": "Chi phí",
|
||||
@@ -114,7 +115,9 @@
|
||||
"runs": {
|
||||
"allSessionsLink": "Xem tất cả phiên Claude Code →",
|
||||
"attached": "Đã gắn vào run đang chạy",
|
||||
"scopeNote": "Chỉ hiển thị các run bạn khởi chạy từ dashboard này.",
|
||||
"externalBadge": "ngoài dashboard",
|
||||
"externalHint": "Phiên này khởi chạy ngoài dashboard nên không có terminal để attach. Resume sẽ mở một `claude --resume` mới trong tmux tại đúng thư mục đó.",
|
||||
"scopeNote": "Các run khởi chạy từ dashboard này, cùng những phiên Claude Code đang chạy bên ngoài.",
|
||||
"started": "Bắt đầu lúc {{when}}",
|
||||
"switcher": "Run đang chạy",
|
||||
"switcherEmpty": "Không có run đang chạy",
|
||||
|
||||
@@ -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
|
||||
* launched from the dashboard's Run page, and stream their output. */
|
||||
run: {
|
||||
/**
|
||||
* GET /api/run - currently tracked runs (in-memory handles) plus
|
||||
* concurrency limits.
|
||||
* @returns {@link RunListResponse} — live handles + `maxConcurrent`/`activeCount`.
|
||||
*/
|
||||
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
|
||||
list: () => request<RunListResponse>("/run"),
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** GET /api/run/history - persisted run history from `dashboard_runs`. */
|
||||
history: (limit = 50, options?: { laneId?: number }) => {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (options?.laneId !== undefined) qs.set("laneId", String(options.laneId));
|
||||
return request<{ items: DashboardRunHistoryItem[] }>(`/run/history?${qs.toString()}`);
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** GET /api/run/binary - whether `claude` was found on PATH. */
|
||||
binary: () => request<{ found: boolean; path: string | null }>("/run/binary"),
|
||||
/**
|
||||
* GET /api/run/cwds - suggested working directories for the cwd picker.
|
||||
* @returns `{ items }` — {@link CwdSuggestion} entries (dashboard/home/recent).
|
||||
*/
|
||||
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */
|
||||
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
|
||||
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
|
||||
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
/** GET /api/run/files - path-completion suggestions under `cwd`. */
|
||||
files: (cwd: string, q?: string) => {
|
||||
const qs = new URLSearchParams({ cwd });
|
||||
if (q) qs.set("q", q);
|
||||
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
/**
|
||||
* GET /api/run/:id - one run's current handle; pass `envelopes: true` to
|
||||
* also include its buffered stream-json envelopes (for a page refresh
|
||||
* 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.
|
||||
*/
|
||||
/** GET /api/run/:id - one run's current handle. */
|
||||
get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
|
||||
/** DELETE /api/run/:id - kill the tmux session. */
|
||||
kill: (id: string) =>
|
||||
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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** "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. */
|
||||
export type PermissionMode = "acceptEdits" | "default" | "plan" | "bypassPermissions";
|
||||
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
|
||||
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 {
|
||||
/** Initial prompt/task text passed to the CLI. */
|
||||
prompt: string;
|
||||
mode: RunMode;
|
||||
/** Working directory to launch in; server default applies if omitted. */
|
||||
laneId: number;
|
||||
cwd?: string;
|
||||
/** `--model` value; omitted inherits the CLI's own default (settings.json). */
|
||||
model?: string;
|
||||
permissionMode?: PermissionMode;
|
||||
/** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */
|
||||
resumeSessionId?: string;
|
||||
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,
|
||||
* from POST/GET /api/run - the live counterpart to {@link DashboardRunHistoryItem}.
|
||||
* 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. */
|
||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||
* name (`ccam-lane-<laneId>`). */
|
||||
export interface RunHandle {
|
||||
id: string;
|
||||
/** OS process id; null before the process has actually spawned. */
|
||||
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;
|
||||
laneId: number | null;
|
||||
status: RunStatus;
|
||||
/** Epoch-ms timestamp the process was spawned. */
|
||||
startedAt: number;
|
||||
/** Epoch-ms timestamp the process exited; null while still running. */
|
||||
endedAt: number | null;
|
||||
exitCode: number | null;
|
||||
/** POSIX signal that killed the process (e.g. "SIGTERM"); null otherwise. */
|
||||
signal: string | null;
|
||||
error: string | null;
|
||||
/** Claude Code session id the run created/resumed, once known. */
|
||||
cwd: string | null;
|
||||
model: string | null;
|
||||
permissionMode: PermissionMode | null;
|
||||
effort: EffortLevel | null;
|
||||
resumeSessionId: string | null;
|
||||
/** Claude Code session id this run created/resumed, once known. */
|
||||
sessionId: string | null;
|
||||
/** Count of stream-json envelopes emitted so far. */
|
||||
envelopeCount: number;
|
||||
/** Last chunk of captured stdout, for a quick inline preview. */
|
||||
stdoutTail: string;
|
||||
/** Last chunk of captured stderr, for a quick inline preview. */
|
||||
stderrTail: string;
|
||||
envelopes?: unknown[]; // present when fetched with ?envelopes=1
|
||||
/** ISO timestamp the tmux session was created. */
|
||||
startedAt: string | null;
|
||||
/** Initial prompt preview (first 500 chars), null when not provided. */
|
||||
promptPreview: string | null;
|
||||
}
|
||||
|
||||
/** Response shape of GET /api/run. */
|
||||
export interface RunListResponse {
|
||||
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 {
|
||||
id: string;
|
||||
/** Claude Code session id the run created/resumed; null if never captured. */
|
||||
session_id: string | null;
|
||||
mode: RunMode;
|
||||
cwd: string;
|
||||
model: string | null;
|
||||
permission_mode: PermissionMode | null;
|
||||
effort: EffortLevel | null;
|
||||
resume_session_id: string | null;
|
||||
/** Truncated leading excerpt of the original prompt, for the history list. */
|
||||
prompt_preview: string | null;
|
||||
status: RunStatus;
|
||||
status: "running" | "killed" | "abandoned";
|
||||
exit_code: number | null;
|
||||
started_at: string;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ───── Interactive run streaming ─────
|
||||
// Payloads for the "run a `claude` process from the dashboard" feature. A run is
|
||||
// started via POST /api/run and identified by a `RunHandle` id; the server then
|
||||
// streams stdout envelopes, status transitions, and stdin acks back over the WS.
|
||||
// ───── Terminal run status ─────
|
||||
// A lane's terminal run is a tmux session; its own live/dead state is polled
|
||||
// via GET /api/run, not pushed over the WS envelope path. This payload only
|
||||
// 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
|
||||
* 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`). */
|
||||
/** Payload for the `run_status` WebSocket message. */
|
||||
export interface RunStatusPayload {
|
||||
/** Id of the `RunHandle` whose status changed. */
|
||||
/** The run id (tmux session name, `ccam-lane-<laneId>`). */
|
||||
id: string;
|
||||
/** New run lifecycle state; terminal states are "completed"/"error"/"killed".
|
||||
* "spawning" → the child is being started; "running" → streaming output;
|
||||
* "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). */
|
||||
status: "running" | "gone";
|
||||
/** Epoch-ms timestamp of this transition. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
@@ -1680,9 +1647,7 @@ export interface WSMessage {
|
||||
| DashboardEvent
|
||||
| ImportProgressMessage
|
||||
| UpdateStatusPayload
|
||||
| RunStreamPayload
|
||||
| RunStatusPayload
|
||||
| RunInputAckPayload
|
||||
| CcConfigChangedPayload
|
||||
| AlertEvent
|
||||
| WorkflowRun
|
||||
|
||||
@@ -218,9 +218,7 @@ export function SessionDetail() {
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
const live = r.items.some(
|
||||
(h) => h.sessionId === id && (h.status === "running" || h.status === "spawning")
|
||||
);
|
||||
const live = r.items.some((h) => h.sessionId === id && h.status === "running");
|
||||
setIsDashboardRun(live);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
+405
-938
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @file Workspace.laneCwd.test.tsx
|
||||
* @description The console's working directory must be the selected lane's own
|
||||
* `cwd` — on first paint (the page auto-selects the first lane) and after every
|
||||
* lane switch. A stale cwd is not cosmetic: `RunSetup` submits it verbatim, so
|
||||
* the run would be started in the previously selected lane's folder.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
|
||||
// fixtures it reads have to be lifted with it.
|
||||
const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
|
||||
const laneStub = (id: number, cwd: string) => ({
|
||||
id,
|
||||
title: `lane-${id}`,
|
||||
cwd,
|
||||
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",
|
||||
detected_stage: null,
|
||||
detected_signal: null,
|
||||
slot: null,
|
||||
ports: {},
|
||||
active_feature_id: null,
|
||||
});
|
||||
return {
|
||||
HOME: { kind: "home", path: "/Users/tester", label: "Home" },
|
||||
LANE_A: laneStub(1, "/workspace/alpha"),
|
||||
LANE_B: laneStub(2, "/workspace/beta"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
run: {
|
||||
list: r({ items: [] }),
|
||||
history: r({ items: [] }),
|
||||
binary: r({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: r({ items: [HOME] }),
|
||||
files: r({ items: [] }),
|
||||
start: r({ id: "run-1", status: "running" }),
|
||||
get: r({ id: "run-1", status: "running" }),
|
||||
},
|
||||
lanes: {
|
||||
list: r({
|
||||
lanes: [LANE_A, LANE_B],
|
||||
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
|
||||
}),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
git: r({ available: false }),
|
||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
},
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function cwdInput(): HTMLInputElement {
|
||||
return screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder")) as HTMLInputElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workspace — the console cwd follows the selected lane", () => {
|
||||
it("shows the auto-selected first lane's cwd on load, not the home default", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
expect(cwdInput().value).toBe(LANE_A.cwd);
|
||||
});
|
||||
|
||||
it("swaps the cwd when another lane is selected in the strip", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-tile-2"));
|
||||
await settle();
|
||||
expect(cwdInput().value).toBe(LANE_B.cwd);
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-tile-1"));
|
||||
await settle();
|
||||
expect(cwdInput().value).toBe(LANE_A.cwd);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
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 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 { api } from "../../lib/api";
|
||||
|
||||
@@ -627,3 +633,58 @@ describe("Workspace — proof gallery", () => {
|
||||
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>
|
||||
</div>
|
||||
<div
|
||||
class="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
|
||||
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"
|
||||
class="flex min-h-0 flex-1 gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
||||
data-testid="console-body"
|
||||
class="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||
data-testid="lane-strip"
|
||||
>
|
||||
<header
|
||||
class="flex items-start gap-3"
|
||||
<p
|
||||
class="text-sm text-fg-muted"
|
||||
>
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
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-1 flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
class="rounded-xl border border-border bg-surface-1"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
|
||||
<button
|
||||
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
|
||||
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
|
||||
aria-pressed="true"
|
||||
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
|
||||
title="Multi-turn - keep typing follow-ups while the agent works."
|
||||
type="button"
|
||||
<svg
|
||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
||||
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"
|
||||
>
|
||||
Conversation
|
||||
</button>
|
||||
<button
|
||||
aria-pressed="false"
|
||||
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>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<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"
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
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"
|
||||
<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"
|
||||
>
|
||||
Resume existing session
|
||||
</button>
|
||||
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>
|
||||
</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
|
||||
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
|
||||
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
|
||||
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…"
|
||||
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
|
||||
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
|
||||
· / for slash commands · @ for file references
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<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>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||
>
|
||||
<div
|
||||
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
|
||||
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"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5972,30 +6062,18 @@ exports[`screen snapshots > Run 1`] = `
|
||||
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"
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</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>
|
||||
</button>
|
||||
</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>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Thinking effort
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
@@ -6006,7 +6084,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Inherit from settings
|
||||
Default (model decides)
|
||||
</span>
|
||||
<svg
|
||||
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>
|
||||
<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
|
||||
class="flex items-center gap-3 text-[11px] min-w-0"
|
||||
/>
|
||||
<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=""
|
||||
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-play 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"
|
||||
<div
|
||||
class="flex items-center gap-3 text-[11px] min-w-0"
|
||||
/>
|
||||
<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=""
|
||||
>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
Run
|
||||
</button>
|
||||
<svg
|
||||
class="lucide lucide-play 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"
|
||||
>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,13 @@ import { afterEach, beforeEach } from "vitest";
|
||||
import "./i18n/index";
|
||||
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. */
|
||||
beforeEach(() => {
|
||||
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.
|
||||
|
||||
### 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
|
||||
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/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/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
|
||||
(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/:id/message Send follow-up turn — Body: { text }
|
||||
GET /api/run/:id[?envelopes=1] Handle state; ?envelopes=1 includes the in-memory envelope log
|
||||
DELETE /api/run/:id Stop (SIGTERM → SIGKILL after 5 s)
|
||||
POST /api/run Start/attach run — Body: { laneId, initialPrompt?, ... }
|
||||
GET /api/run/:id Run handle (returns live run state)
|
||||
DELETE /api/run/:id Kill (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. If that session already exists and its pane is idling at a shell prompt, the `claude` command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. 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
|
||||
(`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.
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
```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 } }
|
||||
```
|
||||
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.
|
||||
|
||||
#### cc_config_changed
|
||||
|
||||
|
||||
+29
-45
@@ -309,11 +309,30 @@ 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.
|
||||
- **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.
|
||||
|
||||
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
|
||||
**A console pane follows the lane it shows.** Selecting another lane — from the lane strip in layout 1, or from a pane's own lane picker in layouts 2 and 4 — swaps that pane's cwd, run history and terminal over to the new lane. If the new lane already has a live run in `GET /api/run`, the pane re-attaches to it immediately, so each lane sticks to its own `ccam-lane-<id>` tmux session; if it has none, the pane shows that lane's setup form. Nothing of the previous lane (a half-typed prompt, its terminal) carries over.
|
||||
|
||||
The **working directory** field tracks the selected lane's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (split view restores its pane lanes from `localStorage`), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane is selected at all.
|
||||
|
||||
### Active runs list
|
||||
|
||||
The **Active runs** button in the console header opens the merged run list. It shows three sources in one place, newest first:
|
||||
|
||||
1. live in-memory tmux runs from `GET /api/run`,
|
||||
2. persisted dashboard runs from `GET /api/run/history`,
|
||||
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes them, so two hand-started agents read as "2 active runs".
|
||||
|
||||
An external session is deduped against a dashboard run with the same `session_id`, and sessions from a remote data source (`source !== "local"`) or without a `cwd` are skipped — neither can be resumed on this machine.
|
||||
|
||||
External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one.
|
||||
|
||||
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the lane's `ccam-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `ccam lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `ccam lanes shell` answered `200` while doing nothing at all.
|
||||
|
||||
The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
|
||||
|
||||
### Finding or adopting a lane: `POST /api/lanes/ensure`
|
||||
@@ -375,24 +394,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
|
||||
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
|
||||
|
||||
List all lanes with their current status:
|
||||
@@ -433,6 +434,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.
|
||||
|
||||
## 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.
|
||||
|
||||
The dashboard renders every node in the pipeline in one of five **states**:
|
||||
@@ -931,38 +936,17 @@ The web UI and CLI provide these actions on a lane:
|
||||
|
||||
### 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
|
||||
curl -X POST http://localhost:4820/api/lanes/5/start \
|
||||
curl -X POST http://localhost:4820/api/run \
|
||||
-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
|
||||
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.
|
||||
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.
|
||||
|
||||
**A finished run releases its lane.** When the child truly exits — normally,
|
||||
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"}'
|
||||
```
|
||||
**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>`.
|
||||
|
||||
### 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,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
"adm-zip": "^0.5.16",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.6",
|
||||
@@ -81,6 +82,98 @@
|
||||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"docker:down": "docker compose down"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
"adm-zip": "^0.5.16",
|
||||
"cors": "^2.8.5",
|
||||
"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
|
||||
bootstrap already builds it on session start, so this is usually a no-op — use
|
||||
it to force a rebuild, or to finish the build if the bootstrap's own attempt
|
||||
failed (check `~/.claude/agent-dashboard/runtime/client-build.log`).
|
||||
Rebuild the dashboard bundle, then print the URL. Always forces a rebuild so a
|
||||
stale bundle (e.g. after a fix commit landed but the bootstrap's build predates
|
||||
it) never serves silently. Also finishes the build if the bootstrap's own
|
||||
attempt failed (check `~/.claude/agent-dashboard/runtime/client-build.log`).
|
||||
|
||||
```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
|
||||
print the URL immediately. No server restart is needed — the server already
|
||||
serves from that directory.
|
||||
Install + build takes a few minutes if the client toolchain isn't already
|
||||
installed; otherwise the rebuild itself takes ~10-15s. No server restart is
|
||||
needed — the server already serves from that directory.
|
||||
|
||||
Then help the user open it:
|
||||
|
||||
@@ -25,5 +25,4 @@ uname -s
|
||||
- `Linux` → `xdg-open <url>`
|
||||
- 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
|
||||
asks for a rebuild.
|
||||
Keep the output to a few lines.
|
||||
|
||||
@@ -24,7 +24,7 @@ const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lifecycle-"));
|
||||
process.env.LANES_ROOT = path.join(ROOT, "lanes");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const runs = require("../lib/run-spawner");
|
||||
const runs = require("../lib/pty-run");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
@@ -80,6 +80,29 @@ function makeRunChild({ exitsOnKill }) {
|
||||
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) {
|
||||
const deadline = Date.now() + 5000;
|
||||
let response;
|
||||
@@ -774,7 +797,7 @@ describe("destructive lane lifecycle actions", () => {
|
||||
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");
|
||||
fs.writeFileSync(path.join(lane.cwd, "written-by-run.txt"), "run output\n");
|
||||
const bin = path.join(ROOT, "run-exit-bin");
|
||||
@@ -803,7 +826,7 @@ describe("destructive lane lifecycle actions", () => {
|
||||
expect: destructiveExpect(preflight.body),
|
||||
});
|
||||
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);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
@@ -811,58 +834,57 @@ describe("destructive lane lifecycle actions", () => {
|
||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||
});
|
||||
|
||||
it("resets after a lane run fails to spawn because that handle is already exited", async () => {
|
||||
const lane = await createManagedLane("failed-spawn-reset");
|
||||
const originalPath = process.env.PATH;
|
||||
const emptyBin = path.join(ROOT, "empty-bin");
|
||||
fs.mkdirSync(emptyBin, { recursive: true });
|
||||
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;
|
||||
it("returns ERUNTIMEOUT and leaves the worktree untouched when a tmux session never exits", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const lane = await createManagedLane("await-timeout");
|
||||
const sentinel = path.join(lane.cwd, "must-survive-timeout.txt");
|
||||
fs.writeFileSync(sentinel, "still here\n");
|
||||
|
||||
// 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 reset = await request("POST", `/api/lanes/${lane.id}/reset`, {
|
||||
confirm: true,
|
||||
force: true,
|
||||
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 {
|
||||
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}`);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// The design promises "the lane reports `missing` and only `remove` is
|
||||
// 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(), "");
|
||||
});
|
||||
|
||||
it("refuses a second start while the first run is still live, so no child is orphaned", async () => {
|
||||
// Overwriting run_id while its child is alive orphans that child: a later
|
||||
// reset kills and awaits only the RECORDED run, then `git clean -fd` the
|
||||
// directory the orphan is still writing into.
|
||||
it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => {
|
||||
// Overwriting run_id while its tmux session is alive orphans that session: a later
|
||||
// reset kills and awaits only the RECORDED run's session, then `git clean -fd` the
|
||||
// directory the orphan session is still using.
|
||||
const tmux = require("../lib/tmux");
|
||||
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" });
|
||||
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, handle.id);
|
||||
// Start a run for the lane
|
||||
const restorePath = stubClaudeBinary("start-twice");
|
||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
|
||||
assert.equal(started.status, 200);
|
||||
const runId = started.body.lane.run_id;
|
||||
assert.equal(runs.getRun(runId).status, "running");
|
||||
|
||||
// A start IS allowed again once that run is genuinely finished.
|
||||
await request("POST", `/api/lanes/${lane.id}/stop`);
|
||||
const deadline = Date.now() + 2000;
|
||||
while (runs.getRun(handle.id).status === "spawning" && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
// Mock tmux so the session appears 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 {
|
||||
// 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}`);
|
||||
});
|
||||
|
||||
@@ -973,10 +1018,10 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(typeof runId, "string");
|
||||
runs.killRun(runId);
|
||||
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));
|
||||
}
|
||||
assert.notEqual(runs.getRun(runId).actualExitedAt, null);
|
||||
assert.equal(runs.getRun(runId).status, "gone");
|
||||
} finally {
|
||||
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", () => {
|
||||
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) {
|
||||
const cwd = path.join(ROOT, `ensure-${name}`);
|
||||
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);
|
||||
});
|
||||
|
||||
it("rejects an unknown start mode with 400 and spawns nothing", async () => {
|
||||
const lane = await adoptedLane("bad-mode");
|
||||
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 () => {
|
||||
it("leaves a lane that already has a live run_id untouched during healing", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
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.
|
||||
runs.killRun(stale.id);
|
||||
const deadline = Date.now() + 2000;
|
||||
while (!runs.getRun(stale.id).actualExitedAt && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
// Create a run for this lane.
|
||||
const restorePath = stubClaudeBinary("release-moved-on");
|
||||
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;
|
||||
|
||||
// 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`
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -645,14 +645,12 @@ describe("lane actions", () => {
|
||||
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 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" });
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ENORUN");
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "EUNSUPPORTED");
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,9 +45,25 @@ const CLI = path.join(__dirname, "..", "..", "bin", "ccam.js");
|
||||
let server;
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git", args, { cwd });
|
||||
const child = spawn("git", args, { cwd, env: GIT_ENV });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => (stderr += chunk));
|
||||
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,197 @@
|
||||
/**
|
||||
* @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 existing session's pane runs claude", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "display-message") return "claude\n";
|
||||
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");
|
||||
assert.ok(!calls.some((c) => c[0] === "send-keys"), "must not type over a live agent");
|
||||
});
|
||||
|
||||
it("spawnRun types the argv into an existing session idling at a shell prompt", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "display-message") return "bash\n";
|
||||
return ""; // has-session succeeds → session exists, pane is a shell
|
||||
});
|
||||
pty.spawnRun({ laneId: 8, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
|
||||
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
|
||||
const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l");
|
||||
assert.ok(literal, "expected a literal send-keys with the command line");
|
||||
assert.match(literal[4], /^'claude' .*'--resume' 'abc12345'$/);
|
||||
assert.ok(
|
||||
calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"),
|
||||
"expected the command to be submitted"
|
||||
);
|
||||
});
|
||||
|
||||
it("spawnRun single-quotes an initial prompt typed into an existing shell pane", () => {
|
||||
let literal = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "display-message") return "zsh\n";
|
||||
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", initialPrompt: "don't; rm -rf /" });
|
||||
assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal);
|
||||
});
|
||||
|
||||
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
|
||||
* @description Tests for the Run feature: spawner injection, route
|
||||
* validation, same-origin guard, cwd suggestions, resume validation,
|
||||
* envelope storage / attach, and end-to-end handle lifecycle. Uses a fake
|
||||
* child (PassThrough streams + EventEmitter) so we never invoke the real
|
||||
* `claude` binary.
|
||||
* @description Route tests for the terminal-run feature: same-origin guard,
|
||||
* laneId/cwd validation, spawn/kill/list against a mocked tmux backend.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = 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 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");
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const runs = require("../lib/run-spawner");
|
||||
const runRoute = require("../routes/run");
|
||||
const tmux = require("../lib/tmux");
|
||||
|
||||
let server;
|
||||
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", () => {
|
||||
before(async () => {
|
||||
const app = createApp();
|
||||
server = http.createServer(app);
|
||||
await new Promise((r) => server.listen(0, r));
|
||||
const port = server.address().port;
|
||||
BASE = `http://127.0.0.1:${port}`;
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
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 {
|
||||
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* best-effort temp cleanup */
|
||||
/* best-effort */
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
runs.__reset();
|
||||
tmux.__reset();
|
||||
});
|
||||
|
||||
it("rejects cross-origin browser requests", async () => {
|
||||
@@ -115,429 +90,88 @@ describe("/api/run", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
assert.equal(status, 200);
|
||||
assert.ok(Array.isArray(body.items));
|
||||
assert.deepEqual(body.items, []);
|
||||
});
|
||||
|
||||
it("allows localhost Origin", async () => {
|
||||
const { status } = await fetchJson("/api/run", {
|
||||
headers: { Origin: "http://localhost:5173" },
|
||||
it("POST / rejects a missing laneId", async () => {
|
||||
const { status, body } = await fetchJson("/api/run", { method: "POST", body: { cwd: TMP } });
|
||||
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.deepEqual(body, { ok: true });
|
||||
});
|
||||
|
||||
it("POST / requires prompt", async () => {
|
||||
const { status, body } = await fetchJson("/api/run", { method: "POST", body: {} });
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADPROMPT");
|
||||
});
|
||||
|
||||
it("POST / rejects non-existent cwd", async () => {
|
||||
const { status, body } = await fetchJson("/api/run", {
|
||||
method: "POST",
|
||||
body: { prompt: "hi", mode: "headless", cwd: "/nope/does/not/exist" },
|
||||
it("DELETE /:id returns 404 for a session that doesn't exist", async () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
const e = new Error("gone");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
assert.equal(status, 400);
|
||||
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" });
|
||||
const { status } = await fetchJson("/api/run/ccam-lane-999", { method: "DELETE" });
|
||||
assert.equal(status, 404);
|
||||
});
|
||||
|
||||
it("POST /:id/message rejects empty text", async () => {
|
||||
const { status, body } = await fetchJson("/api/run/x/message", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADINPUT");
|
||||
it("GET /tmux reports availability from the tmux wrapper", async () => {
|
||||
tmux.__setExecImpl(() => "tmux 3.4");
|
||||
const { body } = await fetchJson("/api/run/tmux");
|
||||
assert.equal(body.available, true);
|
||||
});
|
||||
|
||||
// ── /api/run/cwds suggestions ─────────────────────────────────────
|
||||
|
||||
it("GET /cwds returns dashboard + home suggestions with absolute paths", async () => {
|
||||
it("GET /cwds still returns suggested directories (unchanged behavior)", async () => {
|
||||
const { status, body } = await fetchJson("/api/run/cwds");
|
||||
assert.equal(status, 200);
|
||||
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");
|
||||
|
||||
// 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) {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
env: GIT_ENV,
|
||||
}).trim();
|
||||
}
|
||||
|
||||
@@ -29,6 +46,7 @@ function makeBareRemote(parent, name) {
|
||||
// i.e. far older than --initial-branch.
|
||||
execFileSync("git", ["-c", "init.defaultBranch=master", "init", "--bare", repo], {
|
||||
stdio: "ignore",
|
||||
env: GIT_ENV,
|
||||
});
|
||||
return repo;
|
||||
}
|
||||
@@ -36,7 +54,10 @@ function makeBareRemote(parent, name) {
|
||||
function makeWorkingRepo(parent, dir, originUrl) {
|
||||
const repo = path.join(parent, dir);
|
||||
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");
|
||||
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"]);
|
||||
@@ -146,7 +167,10 @@ describe("getUpdatesStatus — no remotes configured", () => {
|
||||
it("returns a soft no-remotes payload", async () => {
|
||||
const repo = path.join(tmpDir, "noremote");
|
||||
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");
|
||||
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"]);
|
||||
|
||||
+10
-1
@@ -248,7 +248,7 @@ db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dashboard_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
mode TEXT NOT NULL,
|
||||
mode TEXT,
|
||||
cwd TEXT NOT NULL,
|
||||
model 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();
|
||||
|
||||
// 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 —
|
||||
// these are additive columns separate from `stage` (the declared stage), so
|
||||
// `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 http = require("http");
|
||||
const swaggerUi = require("swagger-ui-express");
|
||||
const { initWebSocket } = require("./websocket");
|
||||
const { initWebSocket, initPtyWebSocket } = require("./websocket");
|
||||
const { createOpenApiSpec } = require("./openapi");
|
||||
const { redocBundlePath, renderRedocHtml } = require("./lib/redoc");
|
||||
const { writeServerInfo, removeServerInfo, peersSharingDataDir } = require("./lib/server-info");
|
||||
@@ -150,6 +150,7 @@ function createApp() {
|
||||
function startServer(app, port) {
|
||||
const server = http.createServer(app);
|
||||
initWebSocket(server);
|
||||
initPtyWebSocket(server);
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
if (isProduction) {
|
||||
|
||||
@@ -120,8 +120,9 @@ function excludeFromGit(laneDir, line) {
|
||||
* @returns {{servers: string[], profilesSeeded: string[]}}
|
||||
*/
|
||||
async function syncMcp(lane) {
|
||||
const sourceServers = readSourceMcpServers(lane.source_repo);
|
||||
const relocated = relocate(sourceServers, lane.source_repo, lane.cwd);
|
||||
const sourceRepo = lane.source_repo || lane.cwd;
|
||||
const sourceServers = readSourceMcpServers(sourceRepo);
|
||||
const relocated = relocate(sourceServers, sourceRepo, lane.cwd);
|
||||
pinPlaywrightOutputDir(relocated, lane.cwd);
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -130,7 +131,7 @@ async function syncMcp(lane) {
|
||||
);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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,195 @@
|
||||
/**
|
||||
* @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.
|
||||
*
|
||||
* Start/Resume is create-or-reuse: when the lane's tmux session already
|
||||
* exists but its pane sits at a shell prompt, the argv is typed into that
|
||||
* pane instead of being dropped on the floor by a silent adopt.
|
||||
*
|
||||
* 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"]);
|
||||
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
|
||||
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();
|
||||
const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt });
|
||||
|
||||
const record = () => {
|
||||
if (!dashboardRuns) return;
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
tmux.newSession({ name: id, cwd, argv });
|
||||
record();
|
||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||
// The session exists but its pane is sitting at a bare shell prompt — a
|
||||
// `ccam lanes shell`, or a `claude` that already exited. Adopting it
|
||||
// silently here would swallow the whole request: a Resume would spawn no
|
||||
// `--resume` and an initial prompt would never be typed, while the API
|
||||
// still answered 200. Run the argv in the pane the user already sees
|
||||
// instead of erroring or opening a second session.
|
||||
tmux.sendCommand(id, argv);
|
||||
record();
|
||||
}
|
||||
// Pane is running something (a live `claude`, an editor, a build): adopt
|
||||
// silently, same convention as this repo's server port-adoption logic — no
|
||||
// error, no duplicate session. Attaching shows the user what is running.
|
||||
|
||||
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,117 @@
|
||||
/**
|
||||
* @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).
|
||||
* The one place a command line is composed is `sendCommand`, which types into
|
||||
* an existing pane's shell: there the shell IS the consumer, so every argument
|
||||
* is POSIX single-quoted first and sent with `send-keys -l` (literal).
|
||||
* @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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The command currently running in the session's active pane (`bash`, `zsh`,
|
||||
* `claude`, …). Null when tmux can't answer — callers treat that as "unknown,
|
||||
* don't touch the pane".
|
||||
*/
|
||||
function paneCommand(name) {
|
||||
try {
|
||||
return (
|
||||
execImpl(["display-message", "-p", "-t", name, "#{pane_current_command}"]).trim() || null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** POSIX single-quote escaping — the pane is a shell, so argv must be quoted. */
|
||||
function shellQuote(arg) {
|
||||
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type `argv` into an existing session's pane and press Enter. Only ever
|
||||
* called when the pane sits at a shell prompt (see `paneCommand`); `-l` sends
|
||||
* the string literally so no character is read as a tmux key name.
|
||||
*/
|
||||
function sendCommand(name, argv) {
|
||||
execImpl(["send-keys", "-t", name, "-l", argv.map(shellQuote).join(" ")]);
|
||||
execImpl(["send-keys", "-t", name, "Enter"]);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
paneCommand,
|
||||
sendCommand,
|
||||
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.
|
||||
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 = {}) {
|
||||
const timeout = opts.timeout ?? 120_000;
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
"git",
|
||||
args,
|
||||
{ cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8" },
|
||||
{ cwd, timeout, maxBuffer: 2_000_000, encoding: "utf8", env: GIT_ENV },
|
||||
(err, stdout) => {
|
||||
if (err) reject(err);
|
||||
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 proofLib = require("../lib/proof");
|
||||
const { broadcast } = require("../websocket");
|
||||
const runs = require("../lib/run-spawner");
|
||||
const runs = require("../lib/pty-run");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
@@ -69,8 +69,28 @@ function lastEventAge(lane) {
|
||||
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) {
|
||||
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
|
||||
@@ -91,23 +111,6 @@ function broadcastLane(id) {
|
||||
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) => {
|
||||
const lanes = lanesLib.listLanes().map(payload);
|
||||
res.json({
|
||||
@@ -620,8 +623,6 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
});
|
||||
|
||||
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 RUN_EXIT_POLL_MS = 50;
|
||||
// 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));
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (!lane.run_id) return;
|
||||
try {
|
||||
@@ -676,7 +677,7 @@ async function stopLaneRun(lane) {
|
||||
|
||||
const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
|
||||
let run = runs.getRun(lane.run_id);
|
||||
while (run && !run.actualExitedAt) {
|
||||
while (run && run.status !== "gone") {
|
||||
if (Date.now() >= deadline) {
|
||||
throw lifecycleError(
|
||||
"ERUNTIMEOUT",
|
||||
@@ -975,7 +976,7 @@ router.post("/:id/sync-base", sameOriginGuard, async (req, res) => {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
@@ -1079,17 +1080,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
switch (action) {
|
||||
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
|
||||
// reset would kill and await only the RECORDED run, then `git clean -fd`
|
||||
// the directory the orphan is still writing into — the exact hazard
|
||||
// actualExitedAt exists to close. Stop the first run before starting a
|
||||
// the directory the orphan is still writing into. Stop the first run before starting a
|
||||
// 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
|
||||
// `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.
|
||||
if (!current) return { missing: true };
|
||||
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 };
|
||||
}
|
||||
const handle = runs.spawnRun({
|
||||
mode: body.mode || "conversation",
|
||||
laneId: current.id,
|
||||
prompt: body.prompt || "",
|
||||
initialPrompt: body.prompt || "",
|
||||
cwd: current.cwd,
|
||||
model: body.model,
|
||||
permissionMode: body.permissionMode,
|
||||
@@ -1140,23 +1132,13 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
break;
|
||||
}
|
||||
case "message": {
|
||||
if (!lane.run_id) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "ENORUN", message: "lane has no live run" } });
|
||||
}
|
||||
// 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;
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EUNSUPPORTED",
|
||||
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)",
|
||||
},
|
||||
});
|
||||
}
|
||||
case "clear":
|
||||
lanesLib.clearLane(lane.id);
|
||||
|
||||
+30
-64
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @file run.js
|
||||
* @description HTTP routes for the dashboard's Run feature. Spawns and
|
||||
* supervises `claude` processes (headless one-shot or multi-turn
|
||||
* conversation), streams structured envelopes to the client over the
|
||||
* existing WebSocket, and exposes a tiny CRUD-ish surface for run management.
|
||||
* @description HTTP routes for the dashboard's terminal-run feature. Starts,
|
||||
* resumes, kills, and lists tmux-backed `claude` sessions (one per lane),
|
||||
* streamed to the client over a dedicated WebSocket path (see
|
||||
* server/websocket.js `/ws-pty/:runId`) rather than this REST surface.
|
||||
*
|
||||
* Security model:
|
||||
* - Local-first dashboard. The dashboard server is expected to bind to
|
||||
@@ -22,7 +22,8 @@
|
||||
const { Router } = require("express");
|
||||
const fs = require("node:fs");
|
||||
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();
|
||||
|
||||
@@ -96,11 +97,7 @@ function sanitiseCwd(input) {
|
||||
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({
|
||||
items: runs.listRuns(),
|
||||
maxConcurrent: runs.getMaxConcurrent(),
|
||||
activeCount: runs.liveCount(),
|
||||
});
|
||||
res.json({ items: runs.listRuns() });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -125,12 +122,7 @@ router.get("/history", (req, res) => {
|
||||
limit: Number.isFinite(limit) ? limit : 50,
|
||||
laneId: Number.isFinite(laneId) ? laneId : null,
|
||||
});
|
||||
// Cross-reference with live handles so the UI can mark which history
|
||||
// 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);
|
||||
}
|
||||
const liveIds = new Set(runs.listRuns().map((h) => h.id));
|
||||
res.json({
|
||||
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) => {
|
||||
const body = req.body || {};
|
||||
const prompt = typeof body.prompt === "string" ? body.prompt : "";
|
||||
const mode = body.mode === "headless" ? "headless" : "conversation";
|
||||
const model = typeof body.model === "string" && body.model ? body.model : null;
|
||||
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" } });
|
||||
const laneId = Number.parseInt(String(body.laneId ?? ""), 10);
|
||||
if (!Number.isInteger(laneId)) {
|
||||
return res.status(400).json({ error: { code: "EBADLANE", message: "laneId is required" } });
|
||||
}
|
||||
let cwd;
|
||||
try {
|
||||
@@ -286,22 +270,22 @@ router.post("/", (req, res) => {
|
||||
}
|
||||
try {
|
||||
const handle = runs.spawnRun({
|
||||
prompt,
|
||||
mode,
|
||||
laneId,
|
||||
cwd,
|
||||
model,
|
||||
permissionMode,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
model: typeof body.model === "string" && body.model ? body.model : null,
|
||||
permissionMode:
|
||||
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)
|
||||
? 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) {
|
||||
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")) {
|
||||
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) => {
|
||||
// ?envelopes=1 includes the in-memory envelope history so the UI can
|
||||
// re-attach to an active run started elsewhere and see what it missed.
|
||||
const includeEnvelopes = req.query.envelopes === "1";
|
||||
const handle = runs.getRun(req.params.id, { includeEnvelopes });
|
||||
if (!handle) {
|
||||
const handle = runs.getRun(req.params.id);
|
||||
if (!handle || handle.status === "gone") {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
|
||||
}
|
||||
return res.json(handle);
|
||||
|
||||
+98
-15
@@ -9,11 +9,15 @@ const { isHostAllowed, isWebSocketAuthorized } = require("./lib/security");
|
||||
let wss = null;
|
||||
|
||||
function initWebSocket(server) {
|
||||
// Express middleware doesn't run on WS upgrades, so enforce the same Host
|
||||
// allowlist (anti DNS-rebinding) and optional token here (GHSA-gr74-4xfh-6jw9).
|
||||
// `noServer: true` + a manual, path-checked `server.on("upgrade", ...)`
|
||||
// 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({
|
||||
server,
|
||||
path: "/ws",
|
||||
noServer: true,
|
||||
maxPayload: 64 * 1024,
|
||||
verifyClient(info, done) {
|
||||
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) => {
|
||||
ws.isAlive = true;
|
||||
ws.on("pong", () => {
|
||||
@@ -59,6 +71,61 @@ function initWebSocket(server) {
|
||||
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) {
|
||||
if (!wss) return;
|
||||
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.
|
||||
*/
|
||||
function closeWebSocket() {
|
||||
if (!wss) return;
|
||||
wss.clients.forEach((client) => {
|
||||
if (wss) {
|
||||
wss.clients.forEach((client) => {
|
||||
try {
|
||||
client.terminate();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
});
|
||||
try {
|
||||
client.terminate();
|
||||
wss.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
try {
|
||||
wss.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
wss = null;
|
||||
}
|
||||
if (ptyWss) {
|
||||
ptyWss.clients.forEach((client) => {
|
||||
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