diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4b92890..8a98fef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 `/.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 `/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 `/cc-config-backups//..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 ""` 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 `; 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-`. 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_ + 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/` — 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//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=` 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=` 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) | diff --git a/Dockerfile b/Dockerfile index 965cb8b..19d40be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ diff --git a/README.md b/README.md index deb60ec..1731872 100644 --- a/README.md +++ b/README.md @@ -17,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 diff --git a/bin/ccam.js b/bin/ccam.js index 3f38fce..1278ab7 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -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 = [ "[] []", "Show, or switch, which pipeline template a lane renders against", ], + [ + "lanes shell", + "[]", + "Attach a real terminal to the exact tmux session the dashboard uses for a lane's run", + ], [ "lanes reset|remove|purge", " [--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)); } diff --git a/client/package-lock.json b/client/package-lock.json index 9a1b944..acef41a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 62adb1d..82a30e3 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/Tabby/__tests__/brain.test.ts b/client/src/components/Tabby/__tests__/brain.test.ts index a99a325..d607682 100644 --- a/client/src/components/Tabby/__tests__/brain.test.ts +++ b/client/src/components/Tabby/__tests__/brain.test.ts @@ -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", () => { diff --git a/client/src/components/Tabby/brain.ts b/client/src/components/Tabby/brain.ts index 631cb58..44a8e9c 100644 --- a/client/src/components/Tabby/brain.ts +++ b/client/src/components/Tabby/brain.ts @@ -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 }; } diff --git a/client/src/components/run/RunConsole.tsx b/client/src/components/run/RunConsole.tsx deleted file mode 100644 index e8f5342..0000000 --- a/client/src/components/run/RunConsole.tsx +++ /dev/null @@ -1,1097 +0,0 @@ -/** - * @file RunConsole.tsx - * @description The run console: everything that renders one run's live - * conversation and drives its next turn. Moved verbatim out of `pages/Run.tsx` - * (where it was `RunSession`) so the Run page and the Workspace page can both - * mount the same console. - * - * Three pieces live here: - * - the envelope stream — user turns, assistant markdown, thinking, tool - * uses and tool results, plus the result footer; - * - the token / context-window meter rolled up from the envelope log; - * - the prompt editor with its `/` slash-command and `@` file autocomplete. - * - * Props only: no API call except the `@`-file lookup the editor already owned, - * and no stream subscription — `envelopes` arrives as a prop, so the page keeps - * `useRunStream` and both pages share one subscription per run. - * - * @author Nguyễn Ngọc Trí Vĩ - */ - -import { useEffect, useMemo, useRef, useState } from "react"; -import { Link } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { - Play, - Square, - Send, - RefreshCw, - Sparkles, - Terminal, - CheckCircle2, - XCircle, - Clock, - ExternalLink, - Plus, - AtSign, - Slash as SlashIcon, - FileCode, -} from "lucide-react"; -import { api } from "../../lib/api"; -import type { RunHandle, RunMode } from "../../lib/api"; -import { MarkdownContent } from "../conversation/MarkdownContent"; -import type { - AssistantMessage, - ContentBlock, - Envelope, - ResultEnvelope, - SystemInit, - UserMessage, -} from "../../hooks/useRunStream"; - -// ── Token / context-window meter ────────────────────────────────────── - -interface TokenStats { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - costUsd: number | null; - contextWindow: number | null; -} - -const DEFAULT_CONTEXT_WINDOW = 200_000; - -/** - * Roll up token usage from the in-memory envelope log. Pulls the latest - * `usage` block from `stream_event/message_delta` events (live numbers - * during streaming) and the canonical `result.usage` envelope when the run - * finishes. The 1M-context Opus variants emit `contextWindow` in - * `result.modelUsage`; we surface that to size the meter correctly. - */ -function computeTokens(envelopes: Envelope[]): TokenStats { - // Per-turn rolling counters (overwritten as each new turn's message_start - // arrives). The latest message_start's input + cache numbers reflect the - // current turn's prompt size, which is the right thing to show in the - // "Context" gauge. - let inputTokens = 0; - let cacheReadTokens = 0; - let cacheCreationTokens = 0; - // Output is summed across all completed turns plus the running current - // turn - claude reports output_tokens as a per-turn (per-message) number, - // not cumulative. Without summing, the meter resets every time a new - // `message_start` arrives. - let completedOutputTokens = 0; - let currentTurnOutput = 0; - let costUsd: number | null = null; - let contextWindow: number | null = null; - let sawMessageStart = false; - // While we don't have an authoritative output count from message_delta / - // result, estimate from the char count in the streaming assistant block - // so the meter ticks live as text appears (claude doesn't emit usage on - // every text_delta). - let outputAuthoritativeForCurrent = false; - let streamingChars = 0; - - const commitTurn = () => { - completedOutputTokens += currentTurnOutput; - currentTurnOutput = 0; - outputAuthoritativeForCurrent = false; - streamingChars = 0; - }; - - for (const env of envelopes) { - const e = env as { type?: string }; - if (e.type === "stream_event") { - const ev = ( - env as { - event?: { - type?: string; - usage?: Record; - message?: { usage?: Record }; - }; - } - ).event; - if (!ev) continue; - if (ev.type === "message_start") { - // Roll the previous turn's running output into the cumulative total - // before resetting for this new turn. - if (sawMessageStart) commitTurn(); - sawMessageStart = true; - const u = ev.message?.usage; - if (u) { - inputTokens = u.input_tokens ?? 0; - cacheReadTokens = u.cache_read_input_tokens ?? 0; - cacheCreationTokens = u.cache_creation_input_tokens ?? 0; - currentTurnOutput = u.output_tokens ?? 0; - } - } else if (ev.type === "message_delta") { - const u = ev.usage; - if (u && typeof u.output_tokens === "number") { - // Authoritative running output for the current turn. - currentTurnOutput = u.output_tokens; - outputAuthoritativeForCurrent = true; - } - } - } else if (e.type === "result") { - const r = env as ResultEnvelope & { - modelUsage?: Record< - string, - { - contextWindow?: number; - inputTokens?: number; - outputTokens?: number; - cacheReadInputTokens?: number; - cacheCreationInputTokens?: number; - } - >; - }; - // Result is end-of-run: commit any in-flight current turn first. - if (currentTurnOutput > 0) { - completedOutputTokens += currentTurnOutput; - currentTurnOutput = 0; - outputAuthoritativeForCurrent = false; - } - if (typeof r.total_cost_usd === "number") costUsd = r.total_cost_usd; - if (r.modelUsage && typeof r.modelUsage === "object") { - for (const m of Object.values(r.modelUsage)) { - if (!m || typeof m !== "object") continue; - if (typeof m.contextWindow === "number") contextWindow = m.contextWindow; - // Prefer modelUsage's per-model totals when available - these are - // the canonical per-run numbers. - if (typeof m.inputTokens === "number") inputTokens = m.inputTokens; - if (typeof m.cacheReadInputTokens === "number") cacheReadTokens = m.cacheReadInputTokens; - if (typeof m.cacheCreationInputTokens === "number") - cacheCreationTokens = m.cacheCreationInputTokens; - if (typeof m.outputTokens === "number") { - // modelUsage.outputTokens is the run total for this model - use - // it as the canonical cumulative output, replacing our running - // sum. - completedOutputTokens = m.outputTokens; - } - } - } - } else if (e.type === "system" && (env as SystemInit).model) { - // Heuristic: 1M Opus has [1m] in the model id - const model = (env as SystemInit).model || ""; - if (/\[1m\]/i.test(model)) contextWindow = 1_000_000; - } else if (e.type === "assistant") { - const msg = ( - env as { - message?: { - _streaming?: boolean; - content?: ContentBlock[]; - usage?: { - input_tokens?: number; - output_tokens?: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }; - }; - } - ).message; - if (msg?._streaming) { - streamingChars = 0; - const blocks = msg.content || []; - for (const b of blocks) { - if (b.type === "text") { - streamingChars += ((b as { text?: string }).text || "").length; - } else if (b.type === "thinking") { - streamingChars += ((b as { thinking?: string }).thinking || "").length; - } - } - } else if (msg?.usage) { - // Transcript-derived seed envelopes carry usage but have no - // `message.id` (transcriptToEnvelopes doesn't set one). Live-stream - // canonical envelopes always have an id assigned by message_start, - // and their tokens are already counted via stream_event / commitTurn - // - folding them here would double-count. Use id-presence as the - // discriminator: no id → transcript-seeded → fold; id → live → skip. - const hasId = !!(msg as { id?: string }).id; - if (!hasId) { - const u = msg.usage; - if (typeof u.input_tokens === "number") inputTokens = u.input_tokens; - if (typeof u.cache_read_input_tokens === "number") { - cacheReadTokens = u.cache_read_input_tokens; - } - if (typeof u.cache_creation_input_tokens === "number") { - cacheCreationTokens = u.cache_creation_input_tokens; - } - if (typeof u.output_tokens === "number") { - completedOutputTokens += u.output_tokens; - } - } - } - } - } - - // While we don't have an authoritative output count for the current turn, - // surface the char-based estimate so the meter ticks live during streaming. - if (!outputAuthoritativeForCurrent && streamingChars > 0) { - const estimate = Math.ceil(streamingChars / 4); - if (estimate > currentTurnOutput) currentTurnOutput = estimate; - } - - return { - inputTokens, - outputTokens: completedOutputTokens + currentTurnOutput, - cacheReadTokens, - cacheCreationTokens, - costUsd, - contextWindow, - }; -} - -function formatNum(n: number): string { - if (n < 1000) return String(n); - if (n < 100_000) return (n / 1000).toFixed(1) + "k"; - if (n < 1_000_000) return Math.round(n / 1000) + "k"; - return (n / 1_000_000).toFixed(2) + "M"; -} - -function TokenMeter({ stats }: { stats: TokenStats }) { - const { t } = useTranslation("run"); - const total = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens; - const cap = stats.contextWindow ?? DEFAULT_CONTEXT_WINDOW; - const pct = Math.min(100, Math.round((total / cap) * 100)); - // Colour is the whole warning mechanism here - the meter is one status line, - // so there is no room for a bar plus five labelled figures. - const tone = - pct >= 95 ? "text-status-danger" : pct >= 80 ? "text-status-warning" : "text-fg-secondary"; - return ( -
- - ── - - {`${formatNum(total)} / ${formatNum(cap)} (${pct}%)`} - ↑{formatNum(stats.outputTokens)} - {stats.cacheReadTokens > 0 && ( - - ⚡{formatNum(stats.cacheReadTokens)} - - )} - {stats.costUsd != null && ( - ${stats.costUsd.toFixed(4)} - )} -
- ); -} - -// ── Slash commands (built-in list + user/project/plugin from API) ───── - -export interface SlashCommand { - name: string; - description?: string; - source: "builtin" | "user" | "project" | "plugin"; - filePath?: string; -} - -// Built-in commands the CLI handles itself. We surface them in autocomplete -// with a "CLI only" tag so users know they won't actually execute when -// sent over stream-json stdin. -export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [ - { name: "help", description: "List available commands", source: "builtin" }, - { name: "clear", description: "Clear the conversation", source: "builtin" }, - { name: "config", description: "Open the interactive config menu", source: "builtin" }, - { name: "model", description: "Change model mid-session", source: "builtin" }, - { name: "compact", description: "Compact the conversation context", source: "builtin" }, - { name: "memory", description: "Edit CLAUDE.md", source: "builtin" }, - { name: "hooks", description: "Manage hooks", source: "builtin" }, - { name: "cost", description: "Show session cost", source: "builtin" }, - { name: "agents", description: "List subagents", source: "builtin" }, - { name: "review", description: "Review current changes", source: "builtin" }, - { name: "release-notes", description: "Show CC release notes", source: "builtin" }, - { name: "permissions", description: "Edit permission rules", source: "builtin" }, - { name: "status", description: "Show session status", source: "builtin" }, - { name: "init", description: "Initialise CLAUDE.md from codebase", source: "builtin" }, - { name: "login", description: "Sign in to Claude", source: "builtin" }, - { name: "logout", description: "Sign out", source: "builtin" }, - { name: "exit", description: "Exit the session", source: "builtin" }, - { name: "mcp", description: "Manage MCP servers", source: "builtin" }, - { name: "plugin", description: "Manage plugins", source: "builtin" }, - { name: "output-style", description: "Change output style", source: "builtin" }, -]; - -function commandSourceLabel(s: SlashCommand["source"]): string { - return s === "builtin" - ? "CLI only" - : s === "user" - ? "user" - : s === "project" - ? "project" - : "plugin"; -} - -function commandSourceTone(s: SlashCommand["source"]): string { - return s === "builtin" - ? "bg-surface-4/10 text-fg-secondary border-border-light/30" - : s === "user" - ? "bg-sky-500/10 text-sky-300 border-sky-500/30" - : s === "project" - ? "bg-status-success/10 text-status-success border-status-success/30" - : "bg-violet-500/10 text-violet-300 border-violet-500/30"; -} - -// ── Autocomplete dropdown for slash + @-files ───────────────────────── - -interface AutocompleteState { - kind: "slash" | "file"; - query: string; - // The position in the textarea where the trigger character starts (so we - // can replace from there to the cursor on selection). - triggerStart: number; - cursor: number; -} - -/** - * Tiered slash-command match scoring. Higher = more relevant. Returns 0 for - * "doesn't match, hide it." Tiers in descending priority: - * 1. Exact name match - * 2. Name starts with query - * 3. Word boundary (after `-` / `_` / `.`) starts with query - * 4. Name contains query (earlier index ranks higher) - * 5. Subsequence match across the name - * 6. Description contains query - only when query is at least 3 chars, - * so a single keystroke can't drag in tangential descriptions. - */ -function scoreSlashMatch(name: string, description: string | undefined, q: string): number { - if (!q) return 1; - const n = name.toLowerCase(); - if (n === q) return 1000; - if (n.startsWith(q)) return 800 - Math.min(n.length, 100); - const parts = n.split(/[-_.\s]/); - if (parts.some((p) => p.startsWith(q))) { - return 600 - Math.min(n.length, 100); - } - const idx = n.indexOf(q); - if (idx >= 0) return 400 - Math.min(idx, 100); - if (subsequenceMatch(n, q)) return 200; - if (q.length >= 3) { - const d = (description || "").toLowerCase(); - if (d.includes(q)) return 100; - } - return 0; -} - -function subsequenceMatch(s: string, q: string): boolean { - let i = 0; - for (let k = 0; k < s.length && i < q.length; k++) { - if (s[k] === q[i]) i++; - } - return i === q.length; -} - -function detectAutocomplete(value: string, cursor: number): AutocompleteState | null { - // Look back from the cursor to find the active "token". A token starts at - // the beginning of the line / after whitespace and continues until cursor. - let start = cursor; - while (start > 0) { - const ch = value[start - 1]; - if (!ch || /\s/.test(ch)) break; - start--; - } - const tok = value.slice(start, cursor); - if (tok.startsWith("/") && tok.length >= 1) { - // Only trigger for slash if it's at line start OR right after whitespace. - // The detection above already enforces that. - return { kind: "slash", query: tok.slice(1), triggerStart: start, cursor }; - } - if (tok.startsWith("@") && tok.length >= 1) { - return { kind: "file", query: tok.slice(1), triggerStart: start, cursor }; - } - return null; -} - -interface PromptEditorProps { - value: string; - onChange: (s: string) => void; - onSubmit?: () => void; - placeholder?: string; - rows?: number; - slashCommands: SlashCommand[]; - fileCwd: string; - autoFocus?: boolean; -} - -export function PromptEditor({ - value, - onChange, - onSubmit, - placeholder, - rows = 4, - slashCommands, - fileCwd, - autoFocus, -}: PromptEditorProps) { - const { t } = useTranslation("run"); - const taRef = useRef(null); - const [state, setState] = useState(null); - const [active, setActive] = useState(0); - const [fileSuggestions, setFileSuggestions] = useState([]); - const fileFetchRef = useRef<{ q: string; t: number } | null>(null); - - // Slash filter - tiered scoring so prefix matches outrank arbitrary - // substring hits, name matches outrank description matches, and shorter - // names break ties when scores are equal. - const slashItems = useMemo(() => { - if (!state || state.kind !== "slash") return [] as SlashCommand[]; - const q = state.query.toLowerCase(); - const sourceOrder = { project: 0, user: 1, plugin: 2, builtin: 3 } as const; - if (!q) { - return [...slashCommands].sort( - (a, b) => sourceOrder[a.source] - sourceOrder[b.source] || a.name.localeCompare(b.name) - ); - } - type Scored = { cmd: SlashCommand; score: number }; - const scored: Scored[] = []; - for (const cmd of slashCommands) { - const score = scoreSlashMatch(cmd.name, cmd.description, q); - if (score > 0) scored.push({ cmd, score }); - } - return scored - .sort( - (a, b) => - b.score - a.score || - sourceOrder[a.cmd.source] - sourceOrder[b.cmd.source] || - a.cmd.name.length - b.cmd.name.length || - a.cmd.name.localeCompare(b.cmd.name) - ) - .map((s) => s.cmd); - }, [state, slashCommands]); - - // File fetch (debounced) - useEffect(() => { - if (!state || state.kind !== "file") return; - const ts = Date.now(); - fileFetchRef.current = { q: state.query, t: ts }; - const tid = setTimeout(() => { - if (fileFetchRef.current?.t !== ts) return; - api.run - .files(fileCwd, state.query) - .then((r) => setFileSuggestions(r.items)) - .catch(() => setFileSuggestions([])); - }, 120); - return () => clearTimeout(tid); - }, [state, fileCwd]); - - const items = state?.kind === "file" ? fileSuggestions : slashItems; - - useEffect(() => { - if (active >= items.length) setActive(Math.max(0, items.length - 1)); - }, [items.length, active]); - - const insertChoice = (choice: SlashCommand | string) => { - if (!state || !taRef.current) return; - const ta = taRef.current; - const before = value.slice(0, state.triggerStart); - const after = value.slice(state.cursor); - let inserted: string; - if (state.kind === "slash") { - const c = choice as SlashCommand; - inserted = `/${c.name}`; - } else { - inserted = `@${choice as string}`; - } - const next = before + inserted + (after.startsWith(" ") || after === "" ? "" : " ") + after; - onChange(next); - setState(null); - setActive(0); - // Re-position cursor after the inserted token + a trailing space - requestAnimationFrame(() => { - const pos = before.length + inserted.length + 1; - ta.focus(); - ta.setSelectionRange(pos, pos); - }); - }; - - const onKeyDown = (e: React.KeyboardEvent) => { - if (state && items.length > 0) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setActive((a) => Math.min(items.length - 1, a + 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setActive((a) => Math.max(0, a - 1)); - return; - } - if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) { - e.preventDefault(); - const choice = items[active]; - if (choice) insertChoice(choice); - return; - } - if (e.key === "Tab") { - e.preventDefault(); - const choice = items[active]; - if (choice) insertChoice(choice); - return; - } - if (e.key === "Escape") { - e.preventDefault(); - setState(null); - return; - } - } - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { - e.preventDefault(); - onSubmit?.(); - } - }; - - const onTextareaInput = (e: React.ChangeEvent) => { - onChange(e.target.value); - const ta = e.target; - const next = detectAutocomplete(ta.value, ta.selectionStart || 0); - setState(next); - if (!next) setActive(0); - }; - - const onSelect = (e: React.SyntheticEvent) => { - const ta = e.currentTarget; - const next = detectAutocomplete(ta.value, ta.selectionStart || 0); - setState(next); - }; - - return ( -
-