feat: Claude Code Monitor — lanes, pipelines and a merged workspace

Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
@@ -0,0 +1,119 @@
# Merged Workspace Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Merge the Lanes page and the Run page into one Workspace page at `/run` — lane strip, pipeline map, and a full Claude console for the selected lane — without losing any Run capability.
**Architecture:** Design doc: `docs/superpowers/specs/2026-07-28-workspace-page-design.md` — read it once before Task 1. `client/src/pages/Run.tsx` (3658 lines) is extracted into a hook and three components in three separate mechanical commits, each leaving the existing tests green with only import changes. Only then is the new page composed. Four small server pieces support it: runs start through the lane, an `ensure` endpoint, a `lane_id` on run history, and releasing the lane when a run ends.
**Tech Stack:** React 18 + TypeScript + Vite + Tailwind, Vitest + Testing Library (client); Node 18+, Express, better-sqlite3, `node:test` (server).
## Global Constraints
- Branch: `feat/workspace-page`, cut from the head of `feat/stage-detection` (or of `feat/worktree-lanes` if B has not landed). Never work on `master`.
- Every `.js/.ts/.tsx` file created or modified MUST start with a file overview comment plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. Verify with `bash .claude/skills/file-headers/scripts/check-headers.sh` (must exit 0).
- **Tasks A1-A3 are pure moves.** No behaviour may change, no logic may be "improved" in passing. The existing Run tests must pass with changes to import paths ONLY. If a test needs a real edit to keep passing, stop and report it — that means the move was not pure.
- **Extraction and composition never share a commit.** A1, A2, A3 are refactors; A5 builds the page.
- **The console never writes a lane's stage.** No code path from the console may call `POST /:id/stage`. Only `ccam stage` declares; only detection infers.
- Schema changes are additive with a per-column probe (`try { SELECT col } catch { ALTER }`).
- Preserve existing behavior: `POST /api/run` and every existing WebSocket message type keep working exactly as they do — the CLI and other callers depend on them. `lane_update` stays the only lane WS type.
- Server CommonJS; client React + TypeScript. No new npm dependencies. Exact-value assertions; bounded polling, no bare sleeps. i18n strings in all four locales (`en`, `zh`, `vi`, `ko`), genuinely translated.
- The screens snapshot (`client/src/pages/__tests__/screens.snapshot.test.tsx`) covers `/run`. Read every snapshot diff before accepting it; never regenerate blindly.
- The pre-commit hook runs Prettier plus both suites and takes minutes. Let it finish. NEVER `--no-verify`.
- Baseline at branch point: 857 server tests, 297 client tests (add B's counts if B landed first). Each task leaves `git status --short` empty.
---
## Task 1 (A1): extract `useRunStream`
**Files:** Create `client/src/hooks/useRunStream.ts`; create `client/src/hooks/__tests__/useRunStream.test.tsx`; modify `client/src/pages/Run.tsx`.
**Produces:** `useRunStream(runId: string | null)` returning `{envelopes, status, lastAck}`. It owns everything `Run.tsx` currently does with `run_stream` / `run_status` / `run_input_ack`: the envelope merge (`mergeEnvelope`, `findLastStreamingAssistant`, `findAssistantByMessageId`, `mutateAssistantAt`), the typewriter (`useTypewriterEnvelopes`), and the `eventBus.subscribe` lifecycle. Move those functions; do not rewrite them.
- [ ] **Step 1: write the hook's tests first** — they are new coverage for code that had none: envelopes for the subscribed run id merge in arrival order; an envelope for a different run id is ignored; a streaming assistant envelope updates in place rather than appending; a terminal `run_status` stops further merging; unmounting disposes the subscription (assert the disposer returned by `eventBus.subscribe` was called).
- [ ] **Step 2: run, confirm they fail**`cd client && npx vitest run src/hooks/__tests__/useRunStream.test.tsx`.
- [ ] **Step 3: move the code.** Cut the named functions out of `Run.tsx` into the hook, export them if the tests need them, and have `Run.tsx` call the hook. Delete the now-dead copies. Change nothing else.
- [ ] **Step 4: verify the move was pure**`npm run test:client` (all pre-existing Run tests green, no test bodies edited), `npm run build`, and `git diff client/src/pages/__tests__/` must show no snapshot change.
- [ ] **Step 5:** header audit, commit — `refactor(run): extract useRunStream from the Run page`.
---
## Task 2 (A2): extract `RunConsole`
**Files:** Create `client/src/components/run/RunConsole.tsx`; modify `client/src/pages/Run.tsx`.
**Produces:** `<RunConsole runId prompt onPromptChange onSubmit onStop slashCommands busy />` rendering the envelope stream, the prompt editor with its slash autocomplete (`PromptEditor`, `detectAutocomplete`, `scoreSlashMatch`, `subsequenceMatch`, `commandSourceLabel`, `commandSourceTone`), and the token meter (`TokenMeter`, `computeTokens`, `formatNum`). It consumes `useRunStream` from A1. Props only — no direct API calls, so the same console can serve the Run page and the Workspace page.
- [ ] **Step 1: write the failing test**`client/src/components/run/__tests__/RunConsole.test.tsx`: given envelopes from a mocked `useRunStream`, the assistant text renders; typing `/co` shows the matching slash command and picking one fills the prompt; the token meter shows the computed totals; `onSubmit` fires with the prompt text; `onStop` fires from the stop control.
- [ ] **Step 2: run, confirm it fails.**
- [ ] **Step 3: move the code.** Pure move plus the props boundary. Do not redesign the editor.
- [ ] **Step 4:** `npm run test:client` (pre-existing Run tests green with only import changes), `npm run build`, snapshot unchanged.
- [ ] **Step 5:** header audit, commit — `refactor(run): extract RunConsole from the Run page`.
---
## Task 3 (A3): extract `RunSetup` and `RunHistory`, leave `Run.tsx` thin
**Files:** Create `client/src/components/run/RunSetup.tsx`, `client/src/components/run/RunHistory.tsx`; modify `client/src/pages/Run.tsx`.
**Produces:** `<RunSetup>` owning mode / model / permission-mode / effort / cwd / resume-session pickers, the binary-status check and `LimitationsBanner`; `<RunHistory>` owning past runs, live runs and attach. After this task `Run.tsx` holds only page-level state and composition — report its final line count in your report.
- [ ] **Step 1: write the failing tests** for both components: `RunSetup` reports each selection through its callbacks and surfaces a missing-binary state; `RunHistory` lists history, marks a live run, and fires attach with the right run id.
- [ ] **Step 2: run, confirm they fail.**
- [ ] **Step 3: move the code.**
- [ ] **Step 4:** `npm run test:client`, `npm run build`, snapshot unchanged.
- [ ] **Step 5:** header audit, commit — `refactor(run): extract RunSetup and RunHistory, thin the Run page`.
---
## Task 4 (A4): the server glue
**Files:** Modify `server/db.js`, `server/lib/lanes.js`, `server/routes/lanes.js`, `server/lib/run-spawner.js`, `server/__tests__/lane-lifecycle.test.js`; docs as listed in the constraints.
**Produces:**
- `POST /api/lanes/ensure` `{cwd, title?}``{lane, created: boolean}`. Returns the lane that owns `cwd` (exact match or the longest path-boundary parent, reusing `resolveLaneByCwd`), else creates an `adopted` lane. Behind the same-origin guard. Concurrent calls for the same path must yield ONE lane — rely on the `cwd` UNIQUE constraint and treat the constraint violation as "someone else created it, re-read and return it".
- `mode` accepted by the lane `start` action and passed through to `spawnRun`, so a headless one-shot is reachable through a lane.
- `dashboard_runs.lane_id`, one additive probe, written when a run starts through a lane; `GET /api/run/history` accepts an optional `laneId` filter.
- **A finished run releases its lane.** When the run-spawner observes a child's real exit, clear `run_id` and set `status: "idle"` on the lane holding that `run_id`, and broadcast `lane_update`. Do this without creating a require cycle (`run-spawner` must not import a route module — read how `broadcastLane` is exported and pick the clean direction, or invert it with a callback registered at boot). State in your report which direction you chose and why.
- [ ] **Step 1: write the failing tests:** `ensure` returns the existing lane for an exact path, for a nested path, and creates one otherwise; two concurrent `ensure` calls for the same path create exactly one lane; a lane-started run records `lane_id` in `dashboard_runs` and `GET /api/run/history?laneId=` filters by it; **when a run ends on its own, the lane's `run_id` becomes null and its status returns to `idle`**; a cross-origin `POST /api/lanes/ensure` is refused.
- [ ] **Step 2: run, confirm they fail.**
- [ ] **Step 3: implement.**
- [ ] **Step 4:** `npm run test:server`; regenerate `openapi.yaml` and confirm `git diff openapi.yaml` is empty.
- [ ] **Step 5:** header audit, commit — `feat(lanes): ensure endpoint, run history per lane, release the lane when a run ends`.
---
## Task 5 (A5): compose the Workspace page
**Files:** Create `client/src/pages/Workspace.tsx`; modify `client/src/App.tsx`, `client/src/components/Sidebar.tsx`, `client/src/lib/api.ts`, `client/src/i18n/locales/*/lanes.json`; modify `client/src/pages/__tests__/screens.snapshot.test.tsx`.
**Produces:** the merged page at `/run`; `/lanes` redirects to it (`<Navigate to="/run" replace />`); one sidebar entry. Layout top to bottom: lane strip (horizontal scroll, counters, Add) → `PipelineMap` for the selected lane → `RunSetup``RunConsole``RunHistory` filtered to the lane. Selecting a lane switches pipeline, console and history together. Starting a run goes through `POST /api/lanes/:id/start`; choosing a cwd that no lane owns calls `POST /api/lanes/ensure` first. `api.lanes` gains `ensure`.
- [ ] **Step 1: write the failing tests**`client/src/pages/__tests__/Workspace.test.tsx`: the strip lists lanes and the counters match; selecting a lane switches the pipeline and the console's run id; starting a run posts to the LANE start endpoint (assert the URL, not just that something was called); picking an unowned cwd calls `ensure` before `start`; **after a full start-and-message cycle the lane's stage is never posted to** (assert no call to any `/stage` URL).
- [ ] **Step 2: run, confirm they fail.**
- [ ] **Step 3: implement.** Keep `Run.tsx`'s remaining shell only if something still needs it; if the Workspace page fully replaces it, delete it and say so.
- [ ] **Step 4:** `npm run test:client`, `npm run build`. The screens snapshot WILL change here — read the diff, confirm it is only the merged layout, then regenerate.
- [ ] **Step 5:** header audit, commit — `feat(lanes): merge the Lanes and Run pages into one Workspace`.
---
## Task 6 (A6): docs and the seams
**Files:** Modify `docs/LANES.md`, `docs/API.md`, `server/README.md`, `ARCHITECTURE.md`, `README.md`, `server/openapi-extra/lanes.js` (+ regenerate `openapi.yaml`), `CLAUDE.md`.
**Produces:** documentation of the merged page and the new seams: that `/lanes` redirects to `/run`; that the UI starts runs through the lane while `POST /api/run` remains for the CLI; the `ensure` endpoint and when the UI calls it; `dashboard_runs.lane_id`; that a finished run releases its lane. `CLAUDE.md` gains the rule: **the console never writes a lane's stage — declared comes from `ccam stage`, inferred from detection.** Every path and command you print must exist; verify each.
- [ ] **Step 1:** write the docs.
- [ ] **Step 2:** verify every referenced file, route and command exists (`ls`, `grep`, or run it).
- [ ] **Step 3:** `npm run test:server`, `npm run test:client`, `node scripts/generate-openapi-yaml.js` then `git diff openapi.yaml` empty.
- [ ] **Step 4:** header audit, commit — `docs(lanes): document the merged Workspace page and its seams`.
---
## Out of scope
- Any redesign of the prompt editor, the envelope renderer, or the token meter. A1-A3 move them unchanged.
- Multiple concurrent runs per lane. `start` already 409s when one is live.
- Per-lane dependency bootstrap for a fresh worktree (still sub-project D if ever wanted).
- Stage inference — that is sub-project B, and the console must not do it either way.