Compare commits
155 Commits
72f74f9104
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f053051a5d | |||
| 1fa52d1bfc | |||
| 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 | |||
| 7357070fb9 | |||
| f0ae876472 | |||
| f6946d72f4 | |||
| 205f40c29c | |||
| a65ee1512e | |||
| 022b2384ac | |||
| 8a82895c65 | |||
| 5a793e70cc | |||
| 201eae68bb | |||
| 8fcef5a10b | |||
| 67edda77eb | |||
| 87b5e1c3db | |||
| 61443f4814 | |||
| bd829ba1c3 | |||
| 78f6e1be8e | |||
| 9f13769fb4 | |||
| 54299f119e | |||
| e31d261fd7 | |||
| 7fa39687fe | |||
| 77f3805083 | |||
| dd0cb4ad7c | |||
| 5dd4793b88 | |||
| 64c00dea85 | |||
| ab6005afdc | |||
| 14dbfa73e5 | |||
| 57dd4ffcb7 | |||
| 6d8c5399ea | |||
| bc7c5e5431 | |||
| e32c684bf0 | |||
| e7ef7bcef9 | |||
| 99465d2095 | |||
| 5f114d7c6f | |||
| d0f42254ee | |||
| 5e620bd8ab | |||
| e2d516f199 | |||
| f157977783 | |||
| 6d24f9d7ad | |||
| c781fcdb45 | |||
| 0006fabb4c | |||
| 32dc4e432a | |||
| 8fc7a32490 | |||
| 36d66cda07 | |||
| 4b3a1e3d25 | |||
| ef686454b9 | |||
| 60fb7cbaeb | |||
| 8471b3a757 | |||
| 2731b7c8ce | |||
| d25a20089a | |||
| 4da7ef0453 | |||
| f6fa1ea42d | |||
| d2327408fb | |||
| 02b90292c0 | |||
| 727823d75e | |||
| 3fb70a3fc1 | |||
| 4134b3d307 | |||
| 692ba65b0f | |||
| 39c6e1c920 | |||
| 7606653537 | |||
| ed884c6961 | |||
| 5a127df709 | |||
| 40b1d0e0fb | |||
| d12e9410e3 | |||
| a4246528fe | |||
| 77d79a59d7 | |||
| ea0cd3b455 | |||
| 80d49e2c24 | |||
| 9377849ae0 | |||
| e600df236e | |||
| 31f984c750 | |||
| c37933adfe | |||
| b08504858c | |||
| 5d5ea32db6 | |||
| 6cb5fb356c | |||
| fccfa5ad17 | |||
| a545230746 | |||
| 5de66d0b13 | |||
| e6942db7fe | |||
| e9193ae4be | |||
| fcc7a8f2f3 | |||
| 917f0794d5 | |||
| 3a83e849cf | |||
| 5b2f98ab4d | |||
| 5bc4d03740 | |||
| aa01d77ce6 | |||
| 181e0f77ff | |||
| 465dca35e5 | |||
| 6c7554dbdc | |||
| c068f0cac6 | |||
| 1f3a5ff51d | |||
| 4a8725f136 | |||
| 7488a375e3 | |||
| 2312c53788 | |||
| e783a2a946 | |||
| 4d9a385c5e | |||
| 9d145865dd | |||
| d71086f677 | |||
| 113ed01504 | |||
| c024534475 | |||
| c3546f568a | |||
| fa489016b4 | |||
| 9f9879cc3d | |||
| c4b11799dc | |||
| 0ecc2eec24 | |||
| 2c313084a8 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-agent-monitor-plugins",
|
"name": "claude-code-agent-monitor-plugins",
|
||||||
"description": "Official plugin marketplace for Claude Code Agent Monitor — 10 plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
|
"description": "Official plugin marketplace for Claude Code Agent Monitor — the `ccam` plugin installs the dashboard itself (hooks, server, CLI, MCP), plus 10 focused plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.",
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "smartgift",
|
"name": "smartgift",
|
||||||
"url": "https://git.smartgift.io.vn/Smartgift-AI"
|
"url": "https://git.smartgift.io.vn/Smartgift-AI"
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||||
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "ccam",
|
||||||
|
"source": "./",
|
||||||
|
"description": "The dashboard itself. Installs the Claude Code event hooks, boots the local server, puts the `ccam` CLI on PATH and connects the MCP tools — no checkout and no `npm run setup`. Install this one first; the ten focused plugins below all read from the API it provides.",
|
||||||
|
"tags": ["dashboard", "monitoring", "hooks", "mcp", "cli"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "ccam-analytics",
|
"name": "ccam-analytics",
|
||||||
"path": "plugins/ccam-analytics",
|
"path": "plugins/ccam-analytics",
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"name": "ccam",
|
||||||
|
"description": "Claude Code Agent Monitor — the full local-first dashboard as a plugin. Installs the event hooks, boots the dashboard server, puts the `ccam` CLI on PATH, and connects the MCP tools. No checkout, no npm run setup.",
|
||||||
|
"author": {
|
||||||
|
"name": "Nguyễn Ngọc Trí Vĩ",
|
||||||
|
"url": "https://git.smartgift.io.vn/Smartgift-AI"
|
||||||
|
},
|
||||||
|
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||||
|
"repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["dashboard", "monitoring", "sessions", "cost", "lanes", "mcp", "claude-code"],
|
||||||
|
"commands": [
|
||||||
|
"./plugins/ccam/commands/ccam-doctor.md",
|
||||||
|
"./plugins/ccam/commands/ccam-update.md",
|
||||||
|
"./plugins/ccam/commands/ccam-open.md"
|
||||||
|
],
|
||||||
|
"mcpServers": "./plugins/ccam/.mcp.json",
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" Stop",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SubagentStop": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SubagentStop",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" Notification",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-bootstrap.js\"",
|
||||||
|
"timeout": 10,
|
||||||
|
"statusMessage": "Checking the CCAM dashboard..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SessionStart",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionEnd": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" SessionEnd",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" UserPromptSubmit",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
name: ship-feature-lane
|
||||||
|
description: "Autonomous end-to-end feature pipeline for ONE CCAM lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane <requirement>`. Frontloads ALL clarifying questions once, then runs unattended: implement (TDD) → pre-push CI gates + dev preflight → e2e on the feature branch → code review → local QC → senior GO/NO-GO gate → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass and the senior gate says GO — it's finalized when reviewers see it. Declares lane stage at every step via `ccam stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a CCAM lane. NOTE: ticket-filing and post-merge dev-site QC are currently OFF (F's integrations aren't built yet) — this pipeline stops at Stage 14 once dev CI/dev-QC support lands."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ship Feature Lane (CCAM lane pipeline)
|
||||||
|
|
||||||
|
You are running the autonomous feature pipeline for **one CCAM lane**. The human's only interactive touchpoints are **Stage 0 (frontloaded Q&A)** and **merging the PR on GitHub**; everything else runs to completion or to a `blocked` escalation, reporting progress through `ccam stage` (which the dashboard renders).
|
||||||
|
|
||||||
|
## Setup — do this first, every run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LANE_DIR="$(pwd)" # the lane clone IS your cwd — CCAM resolves the lane from this, never a hardcoded path
|
||||||
|
```
|
||||||
|
- CCAM resolves your lane from `cwd` automatically (longest path-boundary prefix match) — there is no marker file to check and no separate assign step. If `ccam stage` or `ccam feature activate` ever fails with "no lane found", you are not inside a lane's working directory; stop and tell the human.
|
||||||
|
- **Pipeline template is this skill's contract, not whatever the lane started on.** Before your first `ccam stage` call, run `ccam lanes pipeline ship-feature` (no id needed — resolves from `cwd` like `ccam stage`; idempotent, a no-op if already set) so every stage below resolves against the 16-node `ship-feature` map. The human is never expected to pick this in Add Lane or anywhere else — you force it to match the skill actually running.
|
||||||
|
- All stage updates go through `ccam stage <stage> [--status <s>] [--evidence "..."]` — **call it at the start of every stage** (this is also the heartbeat, visible on the dashboard).
|
||||||
|
- **Integration toggles: check, don't assume.** `ccam lanes integration tracker`, `ccam lanes integration dev_qc`, `ccam lanes integration ci_wait` each exit 0 (on) or 1 (off), reading the profile's `integrations.env`. No agent exists yet to actually FILE a ticket or run dev-QC even when a toggle reads on (`ticketer`/`dev-qc` are a later task) — so regardless of the check's result, Stage 9 (ticket) stays skipped, Stage 13's dev-QC and dev-CI-wait halves stay skipped, and Stage 10's CI watch keeps using the plain `gh pr checks` path (`ccam ci` doesn't exist yet). Check the toggle where noted below anyway, so the evidence you record is honest about whether the PROFILE wants the integration on, distinct from whether CCAM can act on it yet.
|
||||||
|
- **Heartbeat during long stages.** Implementing (Stage 1), CI waits (Stage 10), and the watch/post-merge polls (Stages 12–13) can run many minutes between stage transitions — bump the heartbeat with `ccam stage <same-stage>` after each commit and on each poll iteration, so the dashboard doesn't false-flag a working lane as stalled.
|
||||||
|
- Profile hooks (`bootstrap`/`boot`/`migrate`/`seed`/`ci-gate`/`e2e`/`health`/`regen`) run through `ccam lanes hook <name> [args…]` and `ccam lanes up`/`down`. Use them; don't reinvent their logic.
|
||||||
|
- **NEVER merge or rebase branches manually.** The ONLY merge that ever happens in this flow is `origin/development` INTO the feature branch, and only through `ccam lanes sync-base` (fetches fresh, pre-checks migration collisions, auto-regenerates generated files — see the note on this command in Stage 2; it is a LATER task, referenced here by its intended contract). There is no other direction: never merge a feature branch into anything locally, never commit on `development`, and never touch `main`.
|
||||||
|
- **NEVER push `origin/development` or `origin/main` (HARD).** `development` moves ONLY when a human merges a PR on GitHub. The only branch you ever push is your own `feat/<slug>` — and only after the senior gate's GO (Stage 8). If you ever find yourself typing `git push` with `development` or `main` on the line: STOP, `ccam stage <current> --status blocked`.
|
||||||
|
|
||||||
|
## Context recovery — after conversation compaction
|
||||||
|
|
||||||
|
Long pipelines outlive the context window. When context is compacted (summarized), re-derive these before continuing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LANE_DIR="$(pwd)" # lane = cwd, resolved fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check your current position:
|
||||||
|
- **Lane state**: `ccam feature show <slug>` (or `ccam lanes` for the lane's own row) — shows current stage, status, feature title, branch, gate decision, PR URL, notes.
|
||||||
|
- **Git branch**: `git rev-parse --abbrev-ref HEAD` — which branch you're on.
|
||||||
|
- **Feature slug**: from the branch name (`feat/X` → `X`), or from `ccam feature list` (the lane's currently-active feature is marked `▶`).
|
||||||
|
|
||||||
|
Resume from the stage shown. If state says `stage=X status=running`, you were mid-stage X when context compacted — re-run that stage from the top (all `ccam` commands are idempotent).
|
||||||
|
- **MCP preflight (fail fast):** if this is the lane's first run, or a required Playwright MCP's `browser_*` tools aren't available in this session, run `ccam lanes mcp sync` — it relocates the source repo's already-configured MCP servers into this lane's `.mcp.json`. If the source repo has none configured (`ENOMCPCONFIG`), tell the human to configure `.mcp.json` manually for the source repo first. Either way, restart the session after syncing so the new config loads — `playwright` and a local-QC MCP are always required for Stage 3/6. Catching this at Stage 0 costs a minute; catching it at Stage 13 strands a merged feature unverified.
|
||||||
|
- **Agents preflight:** if this is the lane's first run through this pipeline, run `ccam lanes agents install` once — it writes the `qc-local` and `senior-gate-reviewer` agent definitions into the lane's own `.claude/agents/` (idempotent; re-running just overwrites with the current templates). Stage 6/7 launch these by `subagent_type`; skipping this leaves those stages unable to find the agent.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- **Publish ONLY after the senior-gate-reviewer returns `VERDICT: GO`.** "Publish" = push the feature branch + open/update the PR (Stage 8). Nothing reviewer-visible exists before GO, and nothing else authorizes it.
|
||||||
|
- **The fix-loop:** any failure in stages 2–7 (gates/preflight, e2e, review, QC, senior gate), any red PR CI that's genuinely yours (Stage 10), and any worth-fixing review comment (Stage 12) → fix on the **feature branch**, and re-run **from Stage 2 through Stage 8** (gates+preflight → e2e → review → QC plan → QC → senior gate → publish/update PR), then the Stage 10 CI watch. Never skip a gate — the full process applies; no shortcuts because "it's just review feedback".
|
||||||
|
- **EXCEPTION — test-only re-entry (browser-QC fast-path).** If the re-entry's change is ENTIRELY test files — `git diff --name-only` since the last browser-QC'd commit matches only test paths — the app's runtime behavior/UI is unchanged from the last QC'd pass. Run this EXACT stage set, nothing else:
|
||||||
|
- **Always run:** Stage 2 (gates + preflight), Stage 7 (senior gate), Stage 8 (publish/update PR), and the Stage 10 tail (CI watch).
|
||||||
|
- **Run only if e2e spec files are among the changed tests:** Stage 3 (boot + e2e on the feature branch). No e2e specs changed → skip it.
|
||||||
|
- **Always SKIP** (runtime UI unchanged): Stage 5 (QC plan), Stage 6 (qc-local). Record `--evidence "QC skipped: test-only change"`.
|
||||||
|
- If the diff contains ANY non-test file → this fast-path does NOT apply; take the full path above. The first pass (not a re-entry) always runs full QC.
|
||||||
|
- **EXCEPTION — localized re-entry (scoped-e2e fast-path).** On a re-entry whose diff since the last fully-validated commit is SMALL and LOCALIZED — only files inside the feature's own surface, NO migrations, NO contract/generated files, NO shared fixtures/utilities, NO dependency changes — you may shrink Stage 3's e2e to a SCOPED run of the specs covering the touched surface: `ccam lanes hook e2e -- <spec files>` (scoped runs still heartbeat + lock + time-bound like the full suite), and browser QC runs SCOPED to the affected QC-Plan scenarios (tell the qc agent exactly which scenario numbers). Know the trade-off: there is no dev-merged full suite anymore — post-merge dev CI + dev-QC (Stage 13, currently off) are normally the integration net; without them, a localized re-entry after this lands is a real gap until F ships. If in doubt whether the change is localized, it isn't — run the full path.
|
||||||
|
- **Run long helpers so they can't be killed mid-flight or hang your turn.** `ccam lanes hook ci-gate`, `ccam lanes up`, `ccam lanes hook e2e` legitimately run 3–20+ minutes (builds, tests, Playwright, lock waits). NEVER invoke them with the default Bash timeout (2 min kills them mid-flight and strands the lane half-done): use `run_in_background: true` and poll the output file until done, or set `timeout: 600000` for the shorter gates. If a helper does die mid-run, don't panic: every hook is idempotent — re-run the step (e.g. re-run `ccam lanes up --no-build` to revive a stack).
|
||||||
|
- **Waiting + polling NEVER use a foreground `sleep`** (the Bash tool blocks it) or `ScheduleWakeup` (that's a `/loop`-only primitive — this pipeline is not a `/loop` session, so it won't sustain your watch). To pace a poll loop or wait out a timer, **background the wait**: run `sleep <secs>` with `run_in_background: true` — you're re-invoked when it exits, and re-invoked the moment a backgrounded Agent or helper finishes, so you never busy-poll for background work. **If you ever can't sustain a wait/loop in this session, set `ccam stage <current> --note "<honest note>"` and STOP — never narrate a watch or loop you are not actually running.**
|
||||||
|
- **e2e: actively poll — never wait on the completion re-invoke alone.** A hung suite never fires it, stranding the lane at `stage=e2e-feature`. When running `ccam lanes hook e2e` (Stage 3): start it `run_in_background: true` AND background a `sleep 90` beside it. Each wake — finished → parse PASS/FAIL; still running → read the e2e log tail (`ccam lanes logs <id> e2e`), bump the heartbeat, and re-background `sleep 90`, UNLESS it's erroring or has run past ~22 min, in which case kill the e2e task and treat it as FAIL → re-enter Stage 2.
|
||||||
|
- **Turn-liveness: never let the pipeline die silently.** A "stalled" lane usually died one of two ways: (a) a turn ended with NOTHING pending — no backgrounded wait, no running helper, no background agent — so nothing ever re-invoked the session; or (b) a transient API error (rate-limit, 529/overload, connection refused) killed the turn mid-stage. Rules: while the pipeline is anywhere between Stage 1 and Stage 14 (done), every turn you end MUST leave at least one re-invoker pending (a `run_in_background` helper/sleep or a background agent) — check before ending the turn. And on ANY resume after an error or a human nudge ("continue"), do not ask questions: re-derive position from lane state (Context recovery above) and continue the stage. If you truly cannot leave a re-invoker, set `--note "watch needs re-trigger: <what to do>"` so the dashboard shows it honestly.
|
||||||
|
- **No retry cap — the phase clock is the signal.** A failing gate/QC/CI/e2e just re-enters the loop (fix on the feature branch, re-run from Stage 2); there is NO automatic block after N attempts. The dashboard shows how long the lane has sat in its current stage, so the human can spot a stuck or endlessly-looping lane and step in. Reserve `--status blocked` for GENUINE blockers you cannot resolve (an ambiguous merge conflict, a hard/unrecoverable error).
|
||||||
|
- **Commit only on the feature branch.** Never commit on `development`/`main`. Stage only intended files (never `git add .` blindly — this repo collects stray build/QA artifacts).
|
||||||
|
- Keep the lane's state truthful: on any stop, set an accurate `ccam stage <stage> --status <status> --note "<why>"`.
|
||||||
|
- **Quality bar (applies to every code change, including fix-loop re-entries and follow-up PRs).** Tests are sharp and meaningful — each pins a real behavior/edge case (happy + negative + boundary), none trivial, redundant, or coverage-padding. Comments are minimal — only the non-obvious *why*, matching the surrounding density; never narrate the *what*. Investigate before fixing (root cause, not symptom — use **systematic-debugging**). Prefer reusing/extending existing code over duplicating it.
|
||||||
|
- **One driver per MCP browser server.** Each MCP server owns ONE browser; two agents driving the SAME server interleave clicks in one tab. The local-QC MCP → the qc-local agent (Stage 6) only; the general `playwright` MCP → the main session for ad-hoc checks only (never while qc-local runs). Parallel agents on DIFFERENT servers are safe by design; a second concurrent driver on the SAME server is never OK.
|
||||||
|
- **Cross-lane etiquette (locks + siblings).** Lanes share one machine and one dev site. Waiting on a cross-lane serializer (`ccam lock acquire <name>`, e.g. around a shared build/e2e step) is NORMAL — it heartbeats while it waits, so you won't look stalled. NEVER free a lock by killing another lane's session or processes, deleting the lock's directory by hand, or shrinking `LOCK_MAX_HOLD`; a dead holder's lock auto-expires on its own. If a lock wait times out: re-try with a longer `--timeout`, or set `--status blocked` with a note and report. Touch ONLY your own lane's clone, state, and locks you hold.
|
||||||
|
|
||||||
|
## Stages
|
||||||
|
|
||||||
|
### 0 — Intake & frontloaded Q&A *(the only interactive part)*
|
||||||
|
Do NOT jump to code. Understand the requirement first.
|
||||||
|
|
||||||
|
- **Restate + quick scan.** Restate the requirement. Do a fast targeted scan of the relevant code (use the `Explore` agent for breadth; the **brainstorming** skill if the requirement is fuzzy) so your questions are grounded in what actually exists.
|
||||||
|
- **Frontloaded Q&A.** Ask the human **every** clarifying question in ONE batch: acceptance criteria, scope / non-goals, UI/UX specifics, data shapes, edge cases, which existing flows it touches. **Sibling-surface check (mandatory):** if your scan shows the app has N parallel surfaces of the pattern the requirement touches (e.g. several foldered areas, several list pages sharing a component) and the requirement names fewer than N, explicitly ask "this exists in [all N places] — apply to all, or only [the named ones]?" A missed sibling here costs a full second pipeline pass when a reviewer catches it on the PR. Then activate a clean feature slot for this run: `ccam feature activate <slug> --title "<short title>"` — Task B's `activate` archives whatever feature was previously active on this lane automatically, so this run's dashboard state starts clean without a separate "init" step. Then mark intake: `ccam stage intake --status running`. Announce "Questions answered — going autonomous now." After this, don't ask the human anything unless you hit a `blocked` escalation.
|
||||||
|
|
||||||
|
### 0b — Investigate & plan *(autonomous)*
|
||||||
|
- `ccam stage plan` — now design a real plan and have it independently challenged before you implement.
|
||||||
|
- **Investigate (autonomous, thorough).** Read the actual code paths, models, existing tests, and conventions the feature touches — `Explore`/`general-purpose` subagents for breadth, then read the key files yourself for depth. Pin down: integration points, data/migration needs, API/contract impact, reuse opportunities, and risks. Use **systematic-debugging** if the feature is a fix (root-cause first, no symptom patches).
|
||||||
|
- **Plan.** Produce a concrete implementation plan (the **writing-plans** skill): approach, files to change, the test strategy (which behaviors/edge cases each test will pin), migration/contract impact, and how each acceptance criterion is met.
|
||||||
|
- **Debate the plan (adversarial review).** Spawn a SEPARATE sub-agent (Agent tool — `Plan` or `general-purpose`) to critique the plan + investigation: missed requirements, wrong assumptions, a simpler approach, unhandled edge cases, acceptance-criteria gaps. Apply the worthwhile critiques (use **receiving-code-review** judgment — verify each point, don't blindly accept or reject). Iterate once or twice until the plan holds up.
|
||||||
|
- Write the Q&A answers **and the agreed plan** to a lane spec file `docs/superpowers/specs/lane-<slug>.md` (gitignored, or add it to `.gitignore` if this is the first one) — the acceptance contract the senior gate checks against.
|
||||||
|
|
||||||
|
### 1 — Implement (TDD, to the plan)
|
||||||
|
- Choose a **single-segment slug** for the feature — lowercase, hyphens, NO slashes. Cut the feature branch from **development** (the PR base): `git fetch origin && git checkout -b feat/<slug> origin/development`.
|
||||||
|
- If Stage 0 activated a placeholder slug different from the final chosen one, reconcile: `ccam feature activate <slug>` — it echoes back the canonicalized slug it actually stored; use THAT for the branch and every later reference.
|
||||||
|
- Implement the agreed plan with the **test-driven-development** skill: failing test → minimal code → green → commit. Frequent small commits.
|
||||||
|
- **Tests must be sharp and meaningful.** Each test pins a real behavior or edge case from the plan / acceptance criteria — cover the happy path, the negative/error path, and boundaries. NO trivial or redundant tests: don't assert constants or framework internals, don't re-test the same path twice, don't pad for coverage. A few precise tests that would actually catch a regression beat many shallow ones.
|
||||||
|
- **Comment only when it earns its place.** Match the surrounding code's comment density. Comment the non-obvious *why* (intent, invariants, gotchas, links to context) — never narrate the *what* the code already says. Delete redundant/boilerplate/restating comments rather than adding them.
|
||||||
|
- If your stack generates an API contract/client and the API changed, regenerate it (`ccam lanes hook regen`) so the contract-check gate passes (stacks without a contract gate skip this).
|
||||||
|
- `ccam stage implementing`
|
||||||
|
|
||||||
|
### 2 — Pre-push CI gates + dev preflight (on the feature branch)
|
||||||
|
- `ccam stage gates --status running` — declare it FIRST. This is also the fix-loop's re-entry point, and the declaration is what moves the lane BACK down the pipeline: detection alone can't (`recordDetection` is forward-only and never overrides a higher declared stage), so a re-entry that skips this line leaves the dashboard showing the stage you already left.
|
||||||
|
- `ccam lanes hook ci-gate` — runs the profile's CI gate (lint / test / contract checks) against an isolated per-lane test DB. On failure: read the output, fix on the feature branch, commit, re-run. Loop until green.
|
||||||
|
- `ccam lanes sync-base --check feat/<slug>` — the dev preflight: fetches and checks the branch against the CURRENT `origin/development` without merging anything. Exit 5 on a migration-number collision: declare `ccam stage migration-collision --status running` (an alias of `gates` — the map stays put, the lane's stage names WHY it is sitting there), rename the printed file to the suggested number on the feature branch (`git mv`, update any in-file references), then re-run Stage 2. Exit 0 with `DEV_DELTA:`/`DEV_OVERLAP:` output otherwise — informational, you do NOT sync the branch for it (GitHub merges non-conflicting histories fine); a large overlapping delta is a heads-up that post-merge behavior may differ from what you test locally.
|
||||||
|
|
||||||
|
### 3 — E2E on the feature branch
|
||||||
|
The e2e hook doesn't run migrations itself — it tests the already-running stack. To exercise the feature's code and any new schema, boot the lane stack with the feature branch first:
|
||||||
|
|
||||||
|
- `ccam stage booting --status running` — an alias of `e2e-feature`; the boot below can take minutes and this says which minutes they are.
|
||||||
|
- `ccam lanes up --qc` — boots with the profile's QC env (mock/stub flags so QC is deterministic, from `QC_BOOT_ENV`), applies the feature branch's own migrations, and reboots the stack. Idempotent; safe to re-run. (The branch was cut from `origin/development`, so this stack IS development + your feature as of the branch point.)
|
||||||
|
- `ccam stage e2e-feature --status running`, then `ccam lanes hook e2e` — Playwright e2e under the e2e lock against the now-booted stack. **This is the only e2e gate in the flow** — there is no dev-merged suite behind it.
|
||||||
|
- On failure: fix on the feature branch, commit, re-run from Stage 2.
|
||||||
|
- **Iterating on a failing spec:** declare `ccam stage e2e-scoped` (alias of `e2e-feature`) so the dashboard shows this is a narrowed run, not the gate, and use scoped runs through the hook — `ccam lanes hook e2e -- <spec file/filter>` — never a bare test-runner invocation in the lane (bare runs skip the cross-lane lock, the hard timeout, and the heartbeat, so the dashboard false-flags STALLED). A scoped green is never the gate; finish with the full suite (unless the localized fast-path applies — see Hard rules).
|
||||||
|
- On success: `ccam stage e2e-feature-passed --status running`
|
||||||
|
|
||||||
|
### 4 — Code review *(no open PR yet — use local diff)*
|
||||||
|
- Run the **`code-review` skill at effort `high`** on the feature diff vs `origin/development` — this is the deterministic code-review gate, not an ad-hoc read. The PR isn't open yet, so point it at the local diff: `git diff origin/development...feat/<slug>` (and `git log origin/development..feat/<slug>` for commits). ONLY if the `code-review` skill is unavailable, fall back to a manual review of that diff (correctness, security, tests, migration/contract safety). The Stage-6 `qc-local` report covers the user-flow review for the senior gate.
|
||||||
|
- Apply the fixes worth making on the feature branch; if you change code, re-run **from Stage 2**.
|
||||||
|
- `ccam stage review`
|
||||||
|
|
||||||
|
### 5 — QC plan *(bound the test scope before any browser QC)*
|
||||||
|
- Author a **QC Plan** the browser-QC agents (Stage 6 now, and a future Stage 13 once dev-QC exists) will execute against — so QC covers everything that matters and nothing that doesn't (no missed scenarios, no wandering into unrelated areas). Derive it from the acceptance points (lane spec) + the real change surface (`git diff origin/development...feat/<slug>` and `--stat`). Three parts:
|
||||||
|
- **In-scope scenarios** (numbered): each acceptance point with positive AND negative cases; adjacent flows sharing routes/components/data with the change; required **state coverage** (reload on each stateful screen touched, one logout→re-login, back/forth nav); and the required **UI/UX layout checks** for every form/screen the feature touches (narrow AND short viewport, expandables open so content exceeds the viewport, fixed chrome not clipped, every control labelled, section headers more prominent than field labels).
|
||||||
|
- **Out-of-scope** (explicit): areas NOT to test because the change cannot affect them — this is what stops QC from over-testing.
|
||||||
|
- **Smoke set**: login + main nav + ≥3 unaffected major areas.
|
||||||
|
- Append it to the lane spec under a `## QC Plan` heading (`docs/superpowers/specs/lane-<slug>.md`) — the same file the senior gate reads. You are the **single writer** of this section; the QC agent only *proposes* additions in its report and you fold them in (Stage 6). This keeps the plan race-free yet living.
|
||||||
|
- `ccam stage qc-plan --status running`
|
||||||
|
|
||||||
|
### 6 — Browser QC via the qc-local agent
|
||||||
|
- **Test-only fast-path:** on a fix-loop re-entry whose change is ENTIRELY test files (see the fix-loop rule), SKIP this stage — the app's runtime UI is unchanged — and record `--evidence "QC skipped: test-only change"`. Otherwise run it:
|
||||||
|
- `ccam stage qc --status running`, then launch the **qc-local** agent (Agent tool, `subagent_type: qc-local` — FOREGROUND; it gates the pipeline; the Setup section's agents preflight already installed it). Give it: the lane's working directory, the feature slug, the feature title, the acceptance points (lane spec), and the **QC Plan** (lane spec, Stage 5) as the authoritative scope to execute against. It owns the whole local browser QC and proof capture (`ccam lanes proof-link` first, then screenshots land under the proof gallery automatically). It runs against the lane's feature-branch stack from Stage 3. Do NOT drive the browser yourself at this stage.
|
||||||
|
- Parse its last line: `LOCAL-QC: PASS` → continue. `LOCAL-QC: FAIL — <reasons>` → fix on the feature branch → re-run from Stage 2. Keep its report — it is the feature user-flow review for the senior gate.
|
||||||
|
- **Fold back discoveries:** if its report lists scenarios it found that weren't in the plan (its "Scenarios discovered during QC" section), add them to the `## QC Plan` in-scope list in the lane spec.
|
||||||
|
|
||||||
|
### 7 — Senior GO/NO-GO gate *(authorizes the publish)*
|
||||||
|
- Launch the **senior-gate-reviewer** agent (Agent tool, `subagent_type: senior-gate-reviewer` — installed the same way as `qc-local`, see Stage 6). Give it: the lane's working directory, the requirement + Stage-0 answers (the lane spec file), the feature branch, the Stage-4 code-review findings + resolutions, the Stage-6 `qc-local` report (the user-flow review), and confirmation that gates/e2e/review/QC passed. The agent inspects the local diff with `git diff origin/development...feat/<slug>` — no open PR is required (and none exists yet).
|
||||||
|
- Parse its final line:
|
||||||
|
- `VERDICT: GO` → proceed to Stage 8.
|
||||||
|
- `VERDICT: NO-GO — <fixes>` → `ccam stage gate-blocked --evidence "NO-GO — <reason>"` (an alias of `gate`, so the map holds while the lane's stage name says the gate refused), then fix on the feature branch and re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve.
|
||||||
|
- `ccam stage gate --evidence "GO"` (or `NO-GO — <reason>`)
|
||||||
|
|
||||||
|
### 8 — Publish: push branch + open/update PR *(GATED — only on GO)*
|
||||||
|
- `ccam stage publishing --status running`
|
||||||
|
- `ccam stage push-revalidate` (alias of `pr-open`) — then re-run the preflight: `ccam lanes sync-base --check feat/<slug>` — development may have moved while you were in QC. A migration collision here (exit 5) sends you back to Stage 2 with the rename; a clean result (exit 0) proceeds to the push.
|
||||||
|
- `git push -u origin feat/<slug>` — first push of the feature branch to remote. All gates have passed before this point; the PR is finalized before reviewers see it.
|
||||||
|
- Open or update the PR **based on and targeting `development`**: `gh pr create --base development --fill` (or `gh pr edit` / the push itself if a prior run already created it). Capture the URL.
|
||||||
|
- `ccam stage pr-open --evidence "<pr-url>"` — the dashboard shows the PR link from here (via `--evidence` in `ccam feature show`/`ccam lanes`).
|
||||||
|
|
||||||
|
### 9 — Ticket *(currently SKIPPED — tracker integration is off)*
|
||||||
|
- Tracker integration is hardcoded off (see Setup). Do nothing here; do not attempt to file a ticket. When F ships `ccam lanes integration tracker`, this stage gets a real implementation. (Check `ccam lanes integration tracker` in the heartbeat: even when on, no ticketer agent exists yet to act on it.)
|
||||||
|
|
||||||
|
### 10 — CI watch on the PR *(non-blocking)*
|
||||||
|
- Check the PR's CI with `gh pr checks` / `gh`. Green → continue to the report + watch — never idle waiting for green.
|
||||||
|
- **Red CI → triage before the fix-loop** (shared CI flakes under multi-lane load; treating every red as your defect wastes cycles):
|
||||||
|
1. Read WHICH job/tests failed with `gh` (there is no `ccam ci` yet — F builds that; use `gh run view`/`gh pr checks --watch` directly).
|
||||||
|
2. **Your tests / your code implicated** → real failure: fix on the feature branch, re-enter from Stage 2.
|
||||||
|
3. **Infra/flake signature** (a job with no test failures, OOM/contention on the shared runner, a hung job with no output, or a test that is green locally on the identical tree) → re-run the workflow via `gh run rerun <run-id> --failed` — ONCE. Still red after the rerun → treat it as real (or escalate with the evidence). Never rerun more than twice, and never push an empty commit to re-trigger CI.
|
||||||
|
4. A **hung** workflow (running way past its normal duration with no output) → `gh run cancel <run-id>` then rerun once.
|
||||||
|
|
||||||
|
### 11 — Report
|
||||||
|
- Post a concise report: PR URL, CI status, what shipped, and that the PR now **awaits a human merge** (this pipeline never merges).
|
||||||
|
- `ccam stage reported --status running`
|
||||||
|
- Do **NOT** clear or reset the lane's feature state. Cleanup is the human's call, from the dashboard — they may still be manually testing.
|
||||||
|
|
||||||
|
### 12 — Watch the PR *(until a human merges or closes it)*
|
||||||
|
- `ccam stage watching-pr --note "watching PR for comments + base conflicts + the merge"`
|
||||||
|
- Loop every ~5 minutes, paced by a **backgrounded** wait so the turn isn't pinned (see the waiting-primitive rule above): run `sleep 300` with `run_in_background: true` — you're re-invoked when it elapses. Each iteration:
|
||||||
|
- Check PR state: `gh pr view <pr_url> --json state,mergeable -q '.state + " " + (.mergeable|tostring)'`, and bump the heartbeat (`ccam stage watching-pr`).
|
||||||
|
- `MERGED` → a human merged it: go to **Stage 13** (post-merge verification).
|
||||||
|
- `CLOSED` (unmerged) → the human rejected/abandoned it: `ccam stage done --status passed --note "PR closed unmerged by human"` → STOP.
|
||||||
|
- `CONFLICTING` → the feature branch conflicts with `development`. **Base guard first:** check the PR's actual base — `gh pr view <pr_url> --json baseRefName -q .baseRefName`. If it is NOT `development` (the base drifted — a human retargeted the PR, or it predates this pipeline), do NOT auto-merge anything: `--status blocked --note "PR base is not development — human decision"` and STOP. If the base IS `development`, declare `ccam stage sync-conflict --status running` (an alias of `gates`, which is also where the re-entry below lands — one declaration, honest about both) and resolve it as real work:
|
||||||
|
- `ccam lanes sync-base feat/<slug>` (merges the latest `origin/development` INTO the feature branch — the only sanctioned merge). A migration-number collision (exit 5) means nothing was merged — rename the printed file on the feature branch, re-run Stage 2, then retry this step.
|
||||||
|
- **Exit 4 — merge conflict, left in place on purpose.** Resolve every conflict thoughtfully on the feature branch — keep `development`'s behavior for code unrelated to this feature, preserve the feature's intent where they overlap; when genuinely ambiguous, STOP and escalate (`--status blocked`, note the files) rather than guess. Never hand-merge a generated contract/client file listed in the profile's `GENERATED_MERGE_PATHS` — the keep-ours driver + regen own them. `git add` ONLY the conflicted files, `git commit --no-edit`, then `ccam lanes sync-base --continue feat/<slug>` (folds any regenerated artifacts into a follow-up commit).
|
||||||
|
- Re-enter the pipeline **from Stage 2 through Stage 8** (the push updates the PR), then return here and keep watching.
|
||||||
|
- For each new PR comment (list with `gh pr view <pr_url> --json comments`, tracking which you've already handled by comment id in your own notes), triage AND **always reply on its thread** (every comment gets a response — no silent handling, so reviewers see it was considered):
|
||||||
|
- **Worth fixing** (reviewer-requested change, real bug, test/doc gap): this is a NEW CHANGE — `ccam stage pr-comment-fix --status running` (alias of `watching-pr`, so the lane reads as *acting on a comment* rather than idly polling), then apply it on the feature branch and re-enter the pipeline **from Stage 2 through Stage 8** (+ Stage 10 CI watch). The full process applies; no shortcuts because "it's just review feedback". **After the fix is pushed, reply to the comment** confirming resolution — what changed + the commit/PR ref — by writing the reply to a file and posting `gh pr comment <pr_url> --body-file <path>` referencing the comment. **Never inline `--body "..."`** — bodies carry backticks/`file:line`/`$(...)` that bash reads as command substitution inside double quotes, which corrupts the comment and trips an approval prompt. Then come back here and keep watching.
|
||||||
|
- **Question / discussion**: answer it via `gh pr comment <pr_url> --body-file <path>` (same file-not-inline rule) — no code change.
|
||||||
|
- **Not worth fixing** (out of scope, working as intended, deferred): **reply with the reasoning** so the reviewer knows why it wasn't actioned (don't just skip it).
|
||||||
|
- **Sign every reply** with a distinct attribution — end each posted body, on its own line, with: `— 🤖 ship-feature-lane pipeline`.
|
||||||
|
- Nothing new → background another `sleep 300` (`run_in_background: true`) and end the turn; you'll be re-invoked for the next poll. A PR can sit for days — that's fine.
|
||||||
|
|
||||||
|
### 13 — Post-merge verification *(currently SKIPPED — CI-wait and dev-QC integrations are off)*
|
||||||
|
- `ccam stage merged --status running --note "PR merged — post-merge verification unavailable (F not built)"`
|
||||||
|
- Both dev-CI-wait and dev-QC are hardcoded off (see Setup). Go straight to Stage 14. When F ships these integrations, this stage gets its real implementation (mirroring Shipyard's: dev CI watch, then a `dev-qc` agent QCing the deployed site, looping to a Stage 15 follow-up-fix pattern on any issue found).
|
||||||
|
|
||||||
|
### 14 — Done
|
||||||
|
- Post the final report: PR merged, that post-merge verification is unavailable pending F.
|
||||||
|
- `ccam stage done --status passed --note "merged; post-merge verification unavailable (F not built)"` → STOP (leave the lane for the human to clear from the dashboard whenever).
|
||||||
|
|
||||||
|
## Escalation
|
||||||
|
Whenever you STOP early (an ambiguous merge conflict, an unexpected/unrecoverable failure, a missing agent this skill depends on), set `ccam stage <current> --status blocked --note "<what the human must decide>"` — the dashboard surfaces it. Then summarize for the human and wait.
|
||||||
@@ -1,28 +1,106 @@
|
|||||||
---
|
---
|
||||||
name: ship-feature
|
name: ship-feature
|
||||||
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates.
|
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. Drives the Superpowers workflow skills and declares each phase with `ccam stage` so the dashboard's pipeline map follows along.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Ship Feature
|
# Ship Feature
|
||||||
|
|
||||||
Use this workflow for medium or large implementation tasks.
|
Use this workflow for medium or large implementation tasks. It does not restate
|
||||||
|
how to plan, test, or review — the Superpowers skills own that. What lives here
|
||||||
|
is the phase order, this repository's own rules, and the stage declaration at
|
||||||
|
each boundary.
|
||||||
|
|
||||||
## Steps
|
For a feature inside a CCAM **lane**, use `ship-feature-lane` instead: it adds
|
||||||
- Explore impacted modules first.
|
the branch/e2e/QC/senior-gate/PR half this skill deliberately leaves out.
|
||||||
- Write a short implementation plan before editing.
|
|
||||||
- Implement smallest coherent diff that satisfies requirements.
|
|
||||||
- Run relevant verification commands.
|
|
||||||
- Update docs when commands, paths, architecture, or behavior changed.
|
|
||||||
|
|
||||||
## Required quality checks
|
## Declaring the stage
|
||||||
- Keep API and websocket contracts stable unless intentionally changed.
|
|
||||||
- Keep destructive operations behind explicit guardrails.
|
|
||||||
- Avoid broad refactors in feature tickets unless requested.
|
|
||||||
|
|
||||||
## Finish checklist
|
Each phase below starts with `ccam stage <node>`, which is what puts the phase
|
||||||
- Tests/build/typecheck completed or explicitly reported as not run.
|
on the lane's pipeline map. The nodes are the `default` template's:
|
||||||
- Changed file set is scoped and intentional.
|
`intake → plan → implement → tests → review → gate → ship → done`.
|
||||||
- User-facing docs updated if behavior changed.
|
|
||||||
|
Set the template yourself, don't rely on how the lane was created: before your
|
||||||
|
first `ccam stage` call, run `ccam lanes pipeline default` (no id needed —
|
||||||
|
resolves from `cwd`; a no-op if the lane is already on it). This is what makes
|
||||||
|
the choice invisible to the human — whichever of `ship-feature` or
|
||||||
|
`ship-feature-lane` actually runs is what decides the template, not a picker
|
||||||
|
they have to get right in advance.
|
||||||
|
|
||||||
|
`ccam stage` needs a lane owning the current directory. If it reports no lane,
|
||||||
|
this repo was never adopted (`ccam lanes add --cwd $(pwd)` fixes it) — carry on
|
||||||
|
with the workflow and skip the stage calls; they are reporting, not control
|
||||||
|
flow. Never skip a phase because its stage call failed.
|
||||||
|
|
||||||
|
Declaring beats detection. The dashboard also *infers* these stages from the
|
||||||
|
Superpowers skill invocations below, but an inference never renders `done` and
|
||||||
|
never overrides a declaration — and detection cannot move a lane BACKWARD past
|
||||||
|
a declared stage, so a rework loop is invisible unless you re-declare the phase
|
||||||
|
you dropped back to.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### intake — understand before touching anything
|
||||||
|
- `ccam stage intake --status running`
|
||||||
|
- Restate the requirement and its success criteria.
|
||||||
|
- Explore the impacted modules: `Explore` agent for breadth, then read the key
|
||||||
|
files yourself. Identify which layers are hit (server / client / mcp / docs /
|
||||||
|
scripts) — `repo-onboarding` if the area is unfamiliar.
|
||||||
|
- Requirement fuzzy or open to more than one reading? **superpowers:brainstorming**.
|
||||||
|
|
||||||
|
### plan — a written plan, challenged before it is code
|
||||||
|
- `ccam stage plan`
|
||||||
|
- **superpowers:writing-plans**: approach, files to change, the test strategy
|
||||||
|
(which behavior each test pins), and how each success criterion is met.
|
||||||
|
- If the task is a bug rather than a feature: **superpowers:systematic-debugging**
|
||||||
|
first. Root cause, not symptom — and grep every caller of the function you
|
||||||
|
are about to change, not just the path the report names.
|
||||||
|
|
||||||
|
### implement — smallest coherent diff
|
||||||
|
- `ccam stage implementing`
|
||||||
|
- **superpowers:test-driven-development**: failing test → minimal code → green.
|
||||||
|
- Smallest diff that satisfies the requirement. Every changed line traces to it.
|
||||||
|
- **file-headers** applies to every source file you create or edit.
|
||||||
|
|
||||||
|
### tests — this repo's verification, not a claim
|
||||||
|
- `ccam stage tests --status running`
|
||||||
|
- Backend changed → `npm run test:server`. Frontend → `npm run test:client`.
|
||||||
|
MCP → `npm run mcp:typecheck` + `npm run mcp:build`.
|
||||||
|
- A UI snapshot diff is reviewed, never blindly regenerated
|
||||||
|
(`cd client && npx vitest run -u` only after you have read the diff).
|
||||||
|
- Record the outcome: `ccam stage tests --evidence "<what passed>"`, or
|
||||||
|
`--result fail` with what failed. A step you could not run is reported as not
|
||||||
|
run, never as passed.
|
||||||
|
|
||||||
|
### review — a real review pass, not a re-read
|
||||||
|
- `ccam stage review`
|
||||||
|
- Run the **code-review** skill on the working diff, or
|
||||||
|
**superpowers:requesting-code-review** when handing it to an agent.
|
||||||
|
- Apply what is worth applying with **superpowers:receiving-code-review**
|
||||||
|
judgment: verify each point, neither blind agreement nor blind rejection.
|
||||||
|
- Changed code in response? Re-run the `tests` phase.
|
||||||
|
|
||||||
|
### gate — evidence before the completion claim
|
||||||
|
- **superpowers:verification-before-completion**. Commands actually run, output
|
||||||
|
actually read. This is the phase that stops "should work" from shipping.
|
||||||
|
- `ccam stage gate --evidence "<what was verified>"`
|
||||||
|
|
||||||
|
### ship — docs, then the user's call
|
||||||
|
- `ccam stage ship`
|
||||||
|
- **update-project-docs** — mandatory for any change to behavior, config,
|
||||||
|
interfaces, events, schema, CLI commands, or features. Not optional, not
|
||||||
|
deferred, not "if asked".
|
||||||
|
- Commit / push / PR **only when the user asks in this turn**. Finishing an
|
||||||
|
implementation is not authorization to commit.
|
||||||
|
- `ccam stage done --status passed --evidence "<what shipped>"`
|
||||||
|
|
||||||
|
## This repository's own rules
|
||||||
|
|
||||||
|
Not restated here. `CLAUDE.md` is loaded in every session and already binds
|
||||||
|
them — backward-compatible API/WebSocket contracts, fail-safe hooks,
|
||||||
|
migration-safe schema changes, the destructive-lane and git-argv guards, and
|
||||||
|
the lane boundaries (the console never writes a stage; the runtime never writes
|
||||||
|
`stage`/`status`/`notes`). A second copy here would only drift out of sync with
|
||||||
|
the first. Read `CLAUDE.md` and `.claude/rules/` for the area you are touching.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
- Checklist template: `references/feature-checklist.md`
|
- Checklist template: `references/feature-checklist.md`
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ i18n architecture: **Supported languages** list, `supportedLngs`, the 15 namespa
|
|||||||
## Tier 3 — situational
|
## Tier 3 — situational
|
||||||
|
|
||||||
- `.env.example` — every env var belongs here with a sane default + comment.
|
- `.env.example` — every env var belongs here with a sane default + comment.
|
||||||
- `INSTALL.md`, `SETUP.md`, `DEPLOYMENT.md`, `docs/DEPLOYMENT.md` — install/run/deploy commands.
|
- `INSTALL.md`, `SETUP.md` — install/run commands.
|
||||||
- `CLAUDE.md`, `AGENTS.md` — agent working guides; update when commands, file locations, or workflows change.
|
- `CLAUDE.md`, `AGENTS.md` — agent working guides; update when commands, file locations, or workflows change.
|
||||||
- `docs/README.md` — docs index; add a link when a new `docs/*.md` is created.
|
- `docs/README.md` — docs index; add a link when a new `docs/*.md` is created.
|
||||||
- `desktop/README.md`, `vscode-extension/README.md`, `statusline/README.md` — surface-specific; update only when that surface changes.
|
- `vscode-extension/README.md`, `statusline/README.md` — surface-specific; update only when that surface changes.
|
||||||
|
|
||||||
## Consistency invariants
|
## Consistency invariants
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
# Port to listen on (default: 4820)
|
# Port to listen on (default: 4820)
|
||||||
# DASHBOARD_PORT=4820
|
# DASHBOARD_PORT=4820
|
||||||
|
|
||||||
|
# Directory the built client is served from in production (default: client/dist).
|
||||||
|
# The `ccam` plugin install points this at ~/.claude/agent-dashboard/runtime/client-dist
|
||||||
|
# because the plugin cache directory is read-only and replaced on every update.
|
||||||
|
# DASHBOARD_CLIENT_DIST=
|
||||||
|
|
||||||
# Interface to bind. SECURITY: defaults to 127.0.0.1 (loopback) so the dashboard
|
# Interface to bind. SECURITY: defaults to 127.0.0.1 (loopback) so the dashboard
|
||||||
# is NOT reachable from the network out of the box (GHSA-gr74-4xfh-6jw9). The
|
# is NOT reachable from the network out of the box (GHSA-gr74-4xfh-6jw9). The
|
||||||
# server reads transcripts, exports all data, and can spawn `claude`, so binding
|
# server reads transcripts, exports all data, and can spawn `claude`, so binding
|
||||||
|
|||||||
+8
-1
@@ -6,7 +6,10 @@ jspm_packages/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
client/dist/
|
client/dist/
|
||||||
mcp/build/
|
# mcp/build/ is deliberately COMMITTED: Claude Code starts a plugin's MCP
|
||||||
|
# servers the moment the session opens, so the artifact has to exist before any
|
||||||
|
# bootstrap could build it. Freshness is enforced by scripts/check-mcp-build.js.
|
||||||
|
!mcp/build/
|
||||||
desktop/out/
|
desktop/out/
|
||||||
desktop/release/
|
desktop/release/
|
||||||
desktop/assets/icon.iconset/
|
desktop/assets/icon.iconset/
|
||||||
@@ -18,6 +21,10 @@ desktop/assets/icon.iconset/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|
||||||
|
# Local lane profile - only present when this repo itself is adopted as a
|
||||||
|
# lane (`ccam lanes add --cwd`); machine-specific runtime config, not source.
|
||||||
|
/.ccam/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
+10
-1
@@ -36,7 +36,16 @@ else
|
|||||||
echo "🎨 No staged files to format."
|
echo "🎨 No staged files to format."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 2. Run tests — commit is blocked unless all pass ────────────────────────
|
# ── 2. Committed MCP build must match mcp/src ───────────────────────────────
|
||||||
|
# mcp/build/ is committed because a plugin's MCP server starts before any
|
||||||
|
# bootstrap could build it. Only enforced when mcp/src is part of this commit,
|
||||||
|
# so unrelated commits are not blocked by a stale artifact.
|
||||||
|
if git diff --cached --name-only --diff-filter=ACMR | grep -q '^mcp/src/'; then
|
||||||
|
echo "🔌 Checking the committed MCP build against mcp/src..."
|
||||||
|
node scripts/check-mcp-build.js
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Run tests — commit is blocked unless all pass ────────────────────────
|
||||||
# Each suite is retried once on failure. The full run executes dozens of test
|
# Each suite is retried once on failure. The full run executes dozens of test
|
||||||
# files concurrently, each starting its own server (plus the CLI suite's
|
# files concurrently, each starting its own server (plus the CLI suite's
|
||||||
# spawned child processes with hard kill timeouts), so a loaded machine can
|
# spawned child processes with hard kill timeouts), so a loaded machine can
|
||||||
|
|||||||
+52
-396
@@ -35,35 +35,13 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|

|
||||||

|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,7 +65,6 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
|||||||
- [Update Notifier Subsystem](#update-notifier-subsystem)
|
- [Update Notifier Subsystem](#update-notifier-subsystem)
|
||||||
- [Tabby Companion Subsystem](#tabby-companion-subsystem)
|
- [Tabby Companion Subsystem](#tabby-companion-subsystem)
|
||||||
- [VS Code Extension Architecture](#vs-code-extension-architecture)
|
- [VS Code Extension Architecture](#vs-code-extension-architecture)
|
||||||
- [Desktop App Architecture (macOS & Windows / Electron)](#desktop-app-architecture-macos--windows--electron)
|
|
||||||
- [Security Considerations](#security-considerations)
|
- [Security Considerations](#security-considerations)
|
||||||
- [Performance Characteristics](#performance-characteristics)
|
- [Performance Characteristics](#performance-characteristics)
|
||||||
- [Deployment Modes](#deployment-modes)
|
- [Deployment Modes](#deployment-modes)
|
||||||
@@ -348,8 +325,7 @@ graph TD
|
|||||||
| `routes/agents.js` | CRUD with status/session_id filtering. PATCH broadcasts `agent_updated`. Agent-list responses (`GET /api/agents`, `GET /api/sessions/:id/agents`) attach a per-agent `cost` via `pricing.attachAgentCosts` — each subagent's OWN cost, computed from its `metadata.tokens` at current rates (main agents get 0; their cost is the session total), so a subagent card shows only what that subagent spent rather than the session total |
|
| `routes/agents.js` | CRUD with status/session_id filtering. PATCH broadcasts `agent_updated`. Agent-list responses (`GET /api/agents`, `GET /api/sessions/:id/agents`) attach a per-agent `cost` via `pricing.attachAgentCosts` — each subagent's OWN cost, computed from its `metadata.tokens` at current rates (main agents get 0; their cost is the session total), so a subagent card shows only what that subagent spent rather than the session total |
|
||||||
| `routes/events.js` | Read-only event listing with session_id filter and pagination |
|
| `routes/events.js` | Read-only event listing with session_id filter and pagination |
|
||||||
| `routes/stats.js` | Single aggregate query returning total/active counts + status distributions |
|
| `routes/stats.js` | Single aggregate query returning total/active counts + status distributions |
|
||||||
| `routes/metrics.js` | Prometheus / OpenMetrics text-exposition endpoint (`GET /api/metrics`) — re-exposes the dashboard's live counters (sessions/agents by status, event + token totals, connected WebSocket clients, configured remote sources, process uptime/RSS, build version) in the v0.0.4 text format for scraping into Prometheus / Grafana. Read-only; reads the same `db.js` prepared statements the REST API uses, so numbers match the UI. Status series are enumerated so a gauge never drops out at zero. Mounted under `/api`, so it sits behind the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard — a non-loopback scraper needs `DASHBOARD_ALLOWED_HOSTS` (+ token if set). A turnkey Prometheus + Grafana stack with four auto-provisioned dashboards lives in `monitoring/` (`npm run monitoring:up` or `npm run docker:full:up`) |
|
| `routes/metrics.js` | Prometheus / OpenMetrics text-exposition endpoint (`GET /api/metrics`) — re-exposes the dashboard's live counters (sessions/agents by status, event + token totals, connected WebSocket clients, configured remote sources, process uptime/RSS, build version) in the v0.0.4 text format for scraping into Prometheus / Grafana. Read-only; reads the same `db.js` prepared statements the REST API uses, so numbers match the UI. Status series are enumerated so a gauge never drops out at zero. Mounted under `/api`, so it sits behind the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard — a non-loopback scraper needs `DASHBOARD_ALLOWED_HOSTS` (+ token if set) |
|
||||||
| `monitoring/` | Optional npm-managed or Docker Compose Prometheus + Grafana stack that scrapes `GET /api/metrics`. Ships four Grafana dashboards (`ccam-overview`, `ccam-sessions-agents`, `ccam-tokens-events`, `ccam-platform`), recording rules (`prometheus/ccam-rules.yml`), a Prometheus 3.x-compatible static HTML console (`prometheus/consoles/index.html`), and lifecycle scripts (`monitoring:install`, `monitoring:up`, `monitoring:verify`). See [`monitoring/README.md`](./monitoring/README.md) |
|
|
||||||
| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning |
|
| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning |
|
||||||
| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` |
|
| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` |
|
||||||
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
|
||||||
@@ -374,10 +350,22 @@ graph TD
|
|||||||
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
|
||||||
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
|
||||||
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
|
||||||
| `lib/stream-json-parser.js` | Newline-delimited JSON line buffer for parsing `claude --output-format stream-json` output. Reassembles arbitrarily chunked stdout into discrete envelopes. Robust: malformed lines are reported via an `onError` callback but never throw |
|
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||||
| `lib/run-spawner.js` | Spawns and supervises `claude` subprocesses for the Run page. Two modes: **headless** (`-p "<prompt>"` in argv, stdin closed, exits after one turn) and **conversation** (`--input-format stream-json`, prompt + follow-ups piped over stdin, multi-turn). Conversation mode also supports `resumeSessionId` → `--resume <id>`; an empty `prompt` is permitted in this case (the spawner skips the initial stdin write so `claude` idles on the resumed transcript until the user POSTs a follow-up via `/run/:id/message`). The argv builder also passes through an optional `effort` (`low`/`medium`/`high`) → `--effort`. Output is always `--output-format stream-json --verbose --include-partial-messages` so the parser yields character-level deltas (`stream_event` envelopes) the UI can render token-by-token; each envelope is broadcast as `run_stream` over the existing WebSocket. Status transitions broadcast as `run_status`. A failed spawn records an actual-exit timestamp too: no child started, so lane teardown can safely proceed instead of waiting for a nonexistent `exit` event. SIGTERM escalation checks that timestamp rather than Node's delivery-acknowledgement `child.killed`, so a child that ignores SIGTERM still receives SIGKILL after five seconds. Concurrency is effectively uncapped (default ceiling 10000 — matches the terminal TUI which has no cap; the cap is sanity-only to prevent fork-bomb footguns from a buggy client; override with `RUN_MAX_CONCURRENT`, NaN-safe). Per-handle bounded envelope log (cap 500) lets late-attaching clients replay history via `?envelopes=1`. The Run page additionally reconciles this in-memory log against the session's on-disk JSONL transcript on every attach (incl. clicking Resume / View on a row) — when the transcript has more user/assistant messages than the spawner saw (e.g., a resumed run whose prior history never traversed stdout), it supersedes; otherwise the spawner's log wins (it has stream_event deltas the transcript doesn't carry until each turn finalizes). This is what makes leaving a resumed run and coming back show the same chat the user saw initially. Completed handles reaped after 5 min; full transcripts persist via the normal hook ingestion pipeline because every spawned `claude` fires hooks like any other CLI session |
|
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `ccam-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live |
|
||||||
| `routes/run.js` | HTTP surface for the Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). cwd sanitization: must be absolute and exist as a directory. `GET /` lists handles + concurrency state. `GET /binary` probes whether `claude` is on `PATH`. `GET /cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /files?cwd=&q=` powers the Run page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /` spawns (accepts `effort` in body). `POST /:id/message` sends a follow-up turn. `GET /:id` returns the handle; `?envelopes=1` includes the in-memory envelope log for re-attach. `DELETE /:id` SIGTERMs (escalates to SIGKILL after 5 s) |
|
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `ccam lanes shell` CLI, etc.), all synced live |
|
||||||
| `routes/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` and `base_branch` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. |
|
| `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 |
|
||||||
|
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.ccam/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
|
||||||
|
| `lib/lane-runtime.js` | Lane stack lifecycle. `upLane` runs `boot` then `health` (never `bootstrap` — that belongs to provision/reset) and leaves processes running on a failed health check, because their logs are the evidence. Before `boot` it also repairs `.env`, ensures the database exists, migrates every boot, and seeds only the boot that created the database (A2). `downLane` kills each recorded pid tree bottom-up (a parent killed first reparents children to init) and only sweeps port listeners when a pid file existed — an unconditional sweep would kill a server the user started on a lane's stale port. `runtimeFacts` **computes** liveness on every read from pid files and probes rather than caching it (plus the database name/Redis index, never a connection string), which is both correct when a process dies unobserved and why adopting a stack after a dashboard restart needs no code at all. `provisionLane`/`resetLaneData`/`removeLaneData` (A2) drive the data-isolation lifecycle at worktree-provision, reset, and remove time — see `docs/LANES.md#data-isolation-database-redis-and-env-a2`. Writes `slot`/`ports` and nothing else on the lane row: `status=running` means an agent is working, not that a server is listening, and conflating them would corrupt lane liveness |
|
||||||
|
| `lib/lane-detect.js` | (A3) Node.js project detection and `.ccam/profile/` scaffolding. `detectNode` reads `package.json` layout (root, or `backend/`+`frontend/`) and `docker-compose.yml` (a service matching `/postgres/i` or `/redis/i`) — read-only, never executes anything. Any detected name written into GENERATED SHELL TEXT (an npm script name, a compose service name) must pass a strict identifier check first; a `profile.env` value is already safe regardless, since that file is parsed, never sourced. `scaffoldProfile` writes the profile, always leaving `migrate`/`seed` as an `exit 0` TODO stub when a database is found rather than guessing a migration tool. `checkProfile` is the read-only hard gate: unresolved `TODO:`, a missing/non-executable hook, or a port already in use all fail it; a missing `~/.ccam/secrets.env` only warns |
|
||||||
|
| `lib/secrets.js` | (A2) Reads `~/.ccam/secrets.env` — machine-level database/Redis credentials, deliberately outside any repository. Parsed with `lane-profile.js`'s literal `KEY=VALUE` reader, never sourced. Falls back to local defaults (with a one-time warning) when the file is absent; refuses to load a file readable by group or world rather than trusting it. Never returned by any route |
|
||||||
|
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
|
||||||
|
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — CCAM stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and CCAM can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
|
||||||
|
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
|
||||||
|
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `ccam lanes proof-link` |
|
||||||
|
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `ccam lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
|
||||||
|
|
||||||
### API Documentation
|
### API Documentation
|
||||||
|
|
||||||
@@ -636,7 +624,7 @@ graph LR
|
|||||||
| `/analytics` | Analytics | `GET /api/analytics` |
|
| `/analytics` | Analytics | `GET /api/analytics` |
|
||||||
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
| `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) |
|
||||||
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
|
| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects/<slug>/memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title |
|
||||||
| `/run` | Workspace | Merged workspace page combining lanes and runs. Spawns `claude` subprocesses with chat-style streaming UI, tied to lanes: the UI opens on a `cwd`, calls `POST /api/lanes/ensure` when no lane owns it yet, then starts runs through `POST /api/lanes/:id/start` (which accepts `mode: "conversation" \| "headless"` and `effort: "low" \| "medium" \| "high"`). **A finished run releases its lane** (clears `run_id`, returns status to `idle`). Displays a horizontal lane strip at the top, the selected lane's pipeline map, and run configuration/console/history below. `GET /api/run/{binary,cwds,files}` for pre-flight + `@`-file autocomplete; `POST /api/run/:id/message` for follow-up turns; `DELETE /api/run/:id` to stop (lane-tied runs go through `POST /api/lanes/:id/stop` instead). `GET /api/run/history?laneId=<n>` lists only that lane's runs. WS messages: `run_stream` (includes `stream_event` deltas), `run_status`, `run_input_ack`, `lane_update`. Streaming pipeline: each WS envelope is dispatched through `flushSync` so React 18 doesn't batch bursts into a single render; a `useTypewriterEnvelopes` hook drips text/thinking deltas via `requestAnimationFrame` so even short replies type in; the merge code preserves `_streaming` and the delta-accumulated content array when claude's canonical `assistant` envelope arrives mid-stream so thinking blocks aren't dropped. Tier 1 TUI parity: collapsible-to-pill limitations banner, slash + `@`-file autocomplete (dropdowns open upward, slash matching uses tiered scoring), live token / context-window meter, status header. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title |
|
| `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=<n>` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `ccam stage` commands. Live / Offline indicator next to the title |
|
||||||
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
|
| `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages |
|
||||||
| `/*` | NotFound | None (static 404 page) |
|
| `/*` | NotFound | None (static 404 page) |
|
||||||
|
|
||||||
@@ -1107,6 +1095,39 @@ flowchart TD
|
|||||||
|
|
||||||
**Preserves existing hooks** -- only adds or updates entries containing `hook-handler.js`.
|
**Preserves existing hooks** -- only adds or updates entries containing `hook-handler.js`.
|
||||||
|
|
||||||
|
### Plugin installs declare the same hooks instead
|
||||||
|
|
||||||
|
When the dashboard is installed as the `ccam` Claude Code plugin, the eight hook
|
||||||
|
entries are declared inline in `.claude-plugin/plugin.json` (each running
|
||||||
|
`${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js`), and `install-hooks.js` is not
|
||||||
|
used at all. `claude plugin install` itself materializes these into
|
||||||
|
`~/.claude/settings.json` with `${CLAUDE_PLUGIN_ROOT}` already resolved to the
|
||||||
|
plugin's cache path — confirmed by installing the plugin for real and
|
||||||
|
inspecting the file, not just reading the docs. Both a checkout install and a
|
||||||
|
plugin install writing hooks at once would POST every event twice — events
|
||||||
|
carry no id, so ingest cannot deduplicate them, and every token and cost figure
|
||||||
|
would double. Two guards keep that from happening silently:
|
||||||
|
|
||||||
|
- `scripts/plugin-bootstrap.js` strips checkout-style `hook-handler.js` entries
|
||||||
|
out of `~/.claude/settings.json` on session start (backing the file up as
|
||||||
|
`settings.json.ccam-bak` first) and logs what it removed. Because the
|
||||||
|
plugin's own entries ALSO contain `hook-handler.js` (just resolved to a cache
|
||||||
|
path instead of a raw filesystem one), a plain substring match cannot tell
|
||||||
|
them apart — `isCheckoutHookEntry()` only treats an entry as removable when
|
||||||
|
its command does NOT resolve under `~/.claude/plugins/cache/`, so the
|
||||||
|
plugin's own legitimate hooks are never touched.
|
||||||
|
- `install-hooks.js` warns when the plugin runtime state exists, and
|
||||||
|
`/ccam-doctor` reports any surviving checkout-style duplicates as a `FAIL`
|
||||||
|
using the same `isCheckoutHookEntry()` predicate.
|
||||||
|
|
||||||
|
`scripts/plugin-bootstrap.js` also owns the rest of the plugin's runtime: the
|
||||||
|
Node >= 22.5 gate (`node:sqlite`), an atomic `mkdir` lock, the dependency
|
||||||
|
install into `~/.claude/agent-dashboard/runtime/`, the `~/.local/bin/ccam`
|
||||||
|
launcher, and the detached server spawn with `NODE_PATH` and
|
||||||
|
`DASHBOARD_CLIENT_DIST` pointed at that runtime directory. It never writes into
|
||||||
|
the plugin cache, which Claude Code garbage-collects and replaces on every
|
||||||
|
update. Full behavior: [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Import Pipeline
|
## Import Pipeline
|
||||||
@@ -1967,7 +1988,7 @@ Cache versioning is controlled by the `CACHE_NAME` constant (`dashboard-v2`). On
|
|||||||
|
|
||||||
`client/src/main.tsx` snapshots `navigator.serviceWorker.controller` before registration and listens for `controllerchange`: when a new SW activates on an already-controlled page, it reloads exactly once so the page picks up the new asset URLs without a hard refresh. The first install (no previous controller) does **not** reload.
|
`client/src/main.tsx` snapshots `navigator.serviceWorker.controller` before registration and listens for `controllerchange`: when a new SW activates on an already-controlled page, it reloads exactly once so the page picks up the new asset URLs without a hard refresh. The first install (no previous controller) does **not** reload.
|
||||||
|
|
||||||
These behaviors are reinforced by explicit `Cache-Control` headers from the production Express static middleware in `server/index.js`: `immutable, max-age=31536000` for `/assets/*`; `no-cache, must-revalidate` for `index.html`, `sw.js`, and `manifest.json`; a short revalidation window for other static files. The SPA fallback `sendFile` sends the same `no-cache` header. The native desktop shell (macOS and Windows) loads the dashboard from this same in-process server (`NODE_ENV=production`), so it inherits the policy automatically.
|
These behaviors are reinforced by explicit `Cache-Control` headers from the production Express static middleware in `server/index.js`: `immutable, max-age=31536000` for `/assets/*`; `no-cache, must-revalidate` for `index.html`, `sw.js`, and `manifest.json`; a short revalidation window for other static files. The SPA fallback `sendFile` sends the same `no-cache` header.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2264,299 +2285,6 @@ For the extension source code, refer to the [vscode-extension/](./vscode-extensi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Desktop App Architecture (macOS & Windows / Electron)
|
|
||||||
|
|
||||||
The `desktop/` workspace ships the dashboard as a **native desktop app** for **macOS** (`Claude Code Monitor.app`, distributed as a `.dmg`) **and Windows** (`Claude Code Monitor.exe`, distributed as an NSIS installer plus a no-install portable build). It is an Electron shell that **embeds the existing Express server in-process** and renders the already-built React client in a `BrowserWindow`. The desktop app does not reimplement the dashboard -- it `require()`s `server/index.js` directly, in the same Node runtime as the Electron main process, and points a Chromium window at it.
|
|
||||||
|
|
||||||
For the user-facing guide (download, install, Gatekeeper / SmartScreen, tray menu, auto-start), see [DESKTOP.md](./DESKTOP.md). For the full contributor/architecture reference -- including build performance, code signing, notarization, and CI details -- see [desktop/README.md](./desktop/README.md).
|
|
||||||
|
|
||||||
### Workspace Position
|
|
||||||
|
|
||||||
`desktop/` is a **sibling workspace**, not an npm-workspaces conversion. It has its own `package.json`, its own `node_modules`, and its own TypeScript toolchain. It pins **Electron 35** (bundled Node 22.16). It consumes the rest of the repo as plain files and touches no other workspace's runtime behavior.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
subgraph repo["Claude-Code-Agent-Monitor (repo root)"]
|
|
||||||
server["server/<br/>Express API · SQLite · WebSocket"]
|
|
||||||
client["client/<br/>React + Vite SPA"]
|
|
||||||
scripts["scripts/<br/>hook installer/handler, import, seed"]
|
|
||||||
mcp["mcp/<br/>local MCP server"]
|
|
||||||
vscode["vscode-extension/"]
|
|
||||||
desktop["desktop/<br/>Electron shell (sibling workspace)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
desktop -->|"require() in-process"| server
|
|
||||||
desktop -->|"loads built SPA from"| client
|
|
||||||
desktop -->|"auto-installs hooks via"| scripts
|
|
||||||
server -->|"serves static"| client
|
|
||||||
|
|
||||||
style desktop fill:#1f6feb,stroke:#1158c7,color:#fff
|
|
||||||
style server fill:#238636,stroke:#196c2e,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
The **only** change outside `desktop/` is a behavior-preserving refactor of `server/index.js` (see [Background Services & Hook Bootstrap](#background-services--hook-bootstrap-1) below). `client/`, `scripts/`, `mcp/`, and `vscode-extension/` are untouched.
|
|
||||||
|
|
||||||
### Process Model
|
|
||||||
|
|
||||||
Electron runs a **main process** (Node.js) and one or more **renderer processes** (Chromium). In this app:
|
|
||||||
|
|
||||||
- The **main process** hosts the embedded Express server _and_ manages the window, tray, and menus. There is **no child process and no IPC** for the server -- it runs inside the main process's own event loop.
|
|
||||||
- The **renderer** is plain Chromium loading `http://127.0.0.1:<port>` -- exactly the same origin a normal browser would use. `preload.ts` is intentionally empty (`contextIsolation: true`, `nodeIntegration: false`, `webSecurity: true`), so the renderer has **zero privileged surface**.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph main["Electron Main Process (Node 22 / Electron 35)"]
|
|
||||||
boot["main.ts<br/>lifecycle"]
|
|
||||||
host["server-host.ts<br/>embedded server"]
|
|
||||||
express["server/index.js<br/>Express + WS + SQLite"]
|
|
||||||
tray["tray.ts"]
|
|
||||||
menu["menu.ts"]
|
|
||||||
host --> express
|
|
||||||
boot --> host
|
|
||||||
boot --> tray
|
|
||||||
boot --> menu
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph renderer["Renderer Process (Chromium)"]
|
|
||||||
win["BrowserWindow<br/>React dashboard"]
|
|
||||||
preload["preload.ts<br/>(empty -- no bridge)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
express -->|"http + ws on 127.0.0.1:port"| win
|
|
||||||
win -.->|loads| preload
|
|
||||||
|
|
||||||
hooks["Claude Code hooks<br/>(separate node processes)"] -->|"POST /api/hooks/event"| express
|
|
||||||
|
|
||||||
style main fill:#0d1117,stroke:#30363d,color:#e6edf3
|
|
||||||
style renderer fill:#161b22,stroke:#30363d,color:#e6edf3
|
|
||||||
```
|
|
||||||
|
|
||||||
### In-Process Server Hosting
|
|
||||||
|
|
||||||
`server-host.ts` is the **only file** that imports `server/index.js`. The dashboard server already exports `{ createApp, startServer, startBackgroundServices }` and serves the built React client (`client/dist`) as static assets in production -- so the host imports that module directly, with no child process, no IPC, and no port marshalling.
|
|
||||||
|
|
||||||
| Component | Responsibility |
|
|
||||||
| --- | --- |
|
|
||||||
| **`main.ts`** | Main-process entry. Single-instance lock, app menu + tray wiring, dashboard window, `Restart Server`, lifecycle (`window-all-closed`, `before-quit`). |
|
|
||||||
| **`server-host.ts`** | In-process Express boot: port discovery, adoption, `better-sqlite3` ABI patch, `startBackgroundServices()` + hook bootstrap, clean DB close. Returns a `ServerHandle`. |
|
|
||||||
| **`window.ts`** | `BrowserWindow` with persisted geometry (`userData/window-state.json`). External links open in the system browser. Sets the window/taskbar `icon` to the colored app logo (`assets/icon.ico` on Windows, `icon.png` elsewhere) so an unpackaged `desktop:dev` run no longer shows the generic Electron icon. |
|
|
||||||
| **`menu.ts` / `tray.ts`** | Native application menu and menu-bar / notification-area (tray) icon. `tray.ts` selects a platform tray image — a macOS template glyph that the OS tints for the menu bar, or the colored `assets/icon.ico` for the Windows notification area (a black template would vanish on the dark taskbar). Tray uses a single-click dropdown (left or right) with a **live status snapshot** queried straight from SQLite at click time — server port, active sessions, working agents, events today — followed by *Open Dashboard*, *Open in Browser*, *Restart Server*, *Show Logs*, *Open at Login* (toggle), and *Quit*. The menu is rebuilt on each open so every value stays current. Snapshot rows are enabled and click-to-open-dashboard rather than disabled (which the OS dims). The application menu's *File ▸ Open Dashboard* (⌘1) is **macOS-only** — there the global menu bar persists after the window hides; on Windows/Linux the window-attached menu can't reopen a hidden window, so reopening is the tray's job. `focusOrCreateWindow` calls `show()` unconditionally so the tray reliably raises a backgrounded/minimized window (a bare `focus()` on Windows often only flashes the taskbar). |
|
|
||||||
| **`login-item.ts`** | Auto-start-at-login toggle through Electron's first-party `app.setLoginItemSettings` API on every platform. On macOS it drives the modern `SMAppService` Login Items (not a `LaunchAgent` plist); on Windows it writes a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry. Login launches are tagged with a `--ccam-hidden` arg so the app can stay tray-only at startup — Windows has no `wasOpenedAtLogin` signal, so the arg is the cross-platform detection mechanism. |
|
|
||||||
| **`shell-path.ts`** | (macOS) Recovers the user's login-shell `PATH` at startup and merges it onto `process.env.PATH`, so the embedded server (and the `claude` CLI it spawns) is not limited to launchd's minimal `PATH`. On Windows the process already inherits the full user `PATH`, so no recovery is needed. |
|
|
||||||
| **`logger.ts`** | File logger to `~/Library/Logs/Claude Code Monitor/desktop.log` (macOS) or `%APPDATA%\Claude Code Monitor\logs\desktop.log` (Windows) -- the main process has no console when launched from Finder / Explorer. |
|
|
||||||
| **`constants.ts`** | Shared identifiers, including the `APP_ID` (`com.vn.smartgift.ccam.desktop`) that `main.ts` sets as the Windows AppUserModelId. |
|
|
||||||
|
|
||||||
`server-host.ts` resolves the directory containing the bundled `server/` and `client/dist/` via `resolveAppRoot()`: `process.resourcesPath/app` when packaged, or the repo root (one directory up from `desktop/`) in development.
|
|
||||||
|
|
||||||
The `ServerHandle` returned to `main.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface ServerHandle {
|
|
||||||
url: string; // e.g. "http://127.0.0.1:4820"
|
|
||||||
port: number;
|
|
||||||
ownedByUs: boolean; // false when an existing server was adopted
|
|
||||||
stop: () => Promise<void>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Port Discovery & Adoption
|
|
||||||
|
|
||||||
On startup `server-host.ts` picks a port, then either adopts an already-healthy server or boots its own. **Adoption** -- `probePort()` connects to `:4820`, then checks that the listener answers `GET /api/health` with `{ status: "ok" }`. If a healthy dashboard server is already running there (e.g. the user ran `npm start` in a terminal), the desktop app **adopts** it rather than double-binding -- no SQLite contention. An adopted server is not owned by the app, so quitting the app leaves it running.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
start["startEmbeddedServer()"] --> forced{"CCAM_DESKTOP_BIND_PORT set?"}
|
|
||||||
forced -->|yes| bind["bind exactly that port<br/>(no adoption, no fallback)"]
|
|
||||||
forced -->|no| adopt{"healthy server<br/>already on :4820?"}
|
|
||||||
adopt -->|yes| reuse["adopt it<br/>ownedByUs = false"]
|
|
||||||
adopt -->|no| pick["pickFreePort()"]
|
|
||||||
|
|
||||||
pick --> p1{":4820 free?"}
|
|
||||||
p1 -->|yes| use4820["use 4820"]
|
|
||||||
p1 -->|no| p2{"any of<br/>:4821–:4829 free?"}
|
|
||||||
p2 -->|yes| usefb["use that"]
|
|
||||||
p2 -->|no| p3{"any of<br/>:49152–:49500 free?"}
|
|
||||||
p3 -->|yes| userand["use that"]
|
|
||||||
p3 -->|no| fail["throw — no free port"]
|
|
||||||
|
|
||||||
bind --> bootsrv["createApp() + startServer()"]
|
|
||||||
use4820 --> bootsrv
|
|
||||||
usefb --> bootsrv
|
|
||||||
userand --> bootsrv
|
|
||||||
bootsrv --> healthy["waitForHealthy()<br/>poll /api/health ≤ 30s"]
|
|
||||||
healthy --> bg["bootstrapOwnedServer()"]
|
|
||||||
bg --> handle["ServerHandle ownedByUs = true"]
|
|
||||||
reuse --> handleR["ServerHandle ownedByUs = false"]
|
|
||||||
|
|
||||||
style reuse fill:#9e6a03,stroke:#7d5300,color:#fff
|
|
||||||
style fail fill:#da3633,stroke:#b62324,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
Port preference order is **4820 → 4821–4829 → a random port in 49152–49500**. Two environment overrides exist primarily for testing: `CCAM_DESKTOP_BIND_PORT` binds an exact port (disabling adoption and fallback, used by the smoke test), and `CCAM_DESKTOP_NO_ADOPT=1` always starts a fresh server. Before `require()`ing the server module, the host sets `NODE_ENV=production`, `DASHBOARD_PORT=<port>`, and `DASHBOARD_DATA_DIR=<userData>/data` (see [Writable Data Directory](#writable-data-directory) below) so the server reads them from `process.env`.
|
|
||||||
|
|
||||||
### Writable Data Directory
|
|
||||||
|
|
||||||
A packaged install directory is **read-only** in practice: on macOS a `.app` bundle installed under `/Applications`, code-signed, or run through **app translocation** cannot write to `Resources/app/`, and on Windows the NSIS install dir under `%ProgramFiles%` (or the read-only mount a portable build runs from) is no place for mutable state. The dashboard's SQLite database and the VAPID keypair (`server/lib/push.js`) are writable state, so they must not live inside the bundle / install dir. Before booting the embedded server, `server-host.ts` creates `app.getPath('userData')/data` and points the server at it via the `DASHBOARD_DATA_DIR` environment variable:
|
|
||||||
|
|
||||||
- `server/db.js` honors `DASHBOARD_DATA_DIR` for the SQLite file.
|
|
||||||
- `server/lib/push.js` honors it for the persisted VAPID keys.
|
|
||||||
|
|
||||||
The resulting per-user location is `~/Library/Application Support/Claude Code Monitor/data/` on macOS and `%APPDATA%\Claude Code Monitor\data\` on Windows. Because this lives outside the bundle / install dir, imported history and persisted events **survive an app reinstall or update** (the Windows NSIS uninstaller keeps this data by default). Without this, writing a database into the read-only install location failed on a packaged build and broke History Import and event persistence.
|
|
||||||
|
|
||||||
The standalone `node server/index.js` path is **unaffected**: `DASHBOARD_DATA_DIR` is unset there, and `server-host.ts` only sets it when it is not already defined -- so `server/db.js` falls back to its usual repo-relative default.
|
|
||||||
|
|
||||||
### Shell `PATH` Recovery (macOS)
|
|
||||||
|
|
||||||
This step is **macOS-only**. A macOS app launched from Finder, the Dock, or Login Items auto-start is spawned by `launchd`, which hands it a **minimal `PATH`** (roughly `/usr/bin:/bin:/usr/sbin:/sbin`) and does **not** source the user's shell profile. The dashboard's "Run Claude" feature (`server/routes/run.js`, `server/lib/run-spawner.js`) spawns the `claude` CLI, which is almost always installed somewhere only the shell `PATH` knows about (`/opt/homebrew/bin`, `~/.local/bin`, `~/.claude/local`, a Node version-manager's bin dir). Under launchd's `PATH`, `claude` cannot be resolved or spawned.
|
|
||||||
|
|
||||||
`shell-path.ts` repairs this **before the server boots**: at startup it runs the user's login+interactive shell once (`$SHELL -ilc`, so `.zprofile`/`.zshrc` are sourced), captures the resulting `PATH` between sentinel markers, and merges it -- plus a fallback list of common CLI install directories -- onto `process.env.PATH`. The merge is order-preserving and deduplicated, so it is idempotent. Because the embedded server runs in the same process, it and every `claude` it spawns inherit the corrected `PATH`. (A `claude` shell _alias_ or _function_ still cannot be spawned -- only a real executable on the `PATH` can.)
|
|
||||||
|
|
||||||
On **Windows** there is no equivalent step: a process launched from Explorer, the Start menu, or the `HKCU\…\Run` startup entry already inherits the full user `PATH`, so the embedded server can resolve `claude` directly.
|
|
||||||
|
|
||||||
### `better-sqlite3` Native-Module Handling
|
|
||||||
|
|
||||||
`better-sqlite3` is the only **native** module in the dependency tree, and a native module must be compiled against the exact Node ABI it runs on. The repo-root copy is built for the **system Node** (so `npm run test:server` works for contributors); Electron ships its **own Node ABI**.
|
|
||||||
|
|
||||||
The desktop workspace solves this without disturbing the root install: the desktop workspace has its own `better-sqlite3`, rebuilt for Electron's Node ABI by `electron-builder install-app-deps` (run in its `postinstall`). `server-host.ts` then installs a one-time, **process-local** patch to `Module._resolveFilename` that redirects `require("better-sqlite3")` -- from anywhere in the embedded server -- to that ABI-correct copy.
|
|
||||||
|
|
||||||
Desktop setup is **guarded** so a missing or unbuilt binary never escapes as a raw node-gyp trace or a runtime crash: `desktop/scripts/preflight.js` (shared by `install.js` and `prebuild.js`) verifies the Electron-ABI binary exists and, when it doesn't, prints actionable, copy-pasteable setup help -- the per-OS C++ toolchain prerequisites (or a no-toolchain alternative that fetches Electron's prebuilt binary directly) -- before exiting non-zero. `desktop:install` runs this on install; the `prebuild` gate enforces it before every `desktop:*` build, turning a would-be runtime failure into a build-time error.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
subgraph desk["desktop/node_modules"]
|
|
||||||
d1["better-sqlite3<br/>rebuilt for Electron's ABI<br/>(electron-builder install-app-deps)"]
|
|
||||||
end
|
|
||||||
subgraph root["node_modules (repo root)"]
|
|
||||||
r1["better-sqlite3<br/>built for system Node<br/>(used by npm run test:server)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
patch["ensureNativeModulesPatched()<br/>overrides Module._resolveFilename"]
|
|
||||||
srv["server/db.js<br/>require('better-sqlite3')"]
|
|
||||||
|
|
||||||
srv -->|"request intercepted"| patch
|
|
||||||
patch -->|"redirected to"| d1
|
|
||||||
patch -.->|"everything else<br/>passes through"| root
|
|
||||||
|
|
||||||
style d1 fill:#238636,stroke:#196c2e,color:#fff
|
|
||||||
style patch fill:#1f6feb,stroke:#1158c7,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
- The patch is installed exactly once, **before** `server/index.js` is `require()`d, and rewrites _only_ `require("better-sqlite3")` -- every other module resolves normally.
|
|
||||||
- `electron-builder.yml` therefore **excludes** the root `better-sqlite3` from the bundle (it would trip `@electron/universal`'s identical-file detector) and `asarUnpack`s the desktop copy (native `.node` files cannot live inside an `asar` archive).
|
|
||||||
- The `compat-sqlite` (`node:sqlite`) fallback remains a safety net -- one reason the desktop app pins **Electron 35**, whose bundled Node 22.16 has `node:sqlite`.
|
|
||||||
|
|
||||||
### Background Services & Hook Bootstrap
|
|
||||||
|
|
||||||
`node server/index.js` runs its production bootstrap from an `if (require.main === module)` block. Because the desktop app **`require()`s** that module, the block never fires -- so the bootstrap was extracted into an exported `startBackgroundServices()` that both paths call. This is a **behavior-preserving refactor** of `server/index.js`: the standalone server path is functionally unchanged.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph standalone["node server/index.js"]
|
|
||||||
s1["require.main === module"] --> s2["startBackgroundServices()"]
|
|
||||||
end
|
|
||||||
subgraph desktopapp["desktop app"]
|
|
||||||
d1["server-host.ts<br/>bootstrapOwnedServer()"] --> d2["startBackgroundServices()"]
|
|
||||||
d1 --> d3["installHooks()"]
|
|
||||||
end
|
|
||||||
|
|
||||||
d2 --> svc
|
|
||||||
s2 --> svc
|
|
||||||
subgraph svc["Background services"]
|
|
||||||
u["update scheduler"]
|
|
||||||
w["cc-watcher (Claude config watcher)"]
|
|
||||||
r["orphaned-run reconciliation"]
|
|
||||||
end
|
|
||||||
|
|
||||||
style d1 fill:#1f6feb,stroke:#1158c7,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
`bootstrapOwnedServer()` runs **once** -- guarded by a module-level flag so a `Restart Server` does not double-register schedulers or watchers -- and:
|
|
||||||
|
|
||||||
1. Calls `startBackgroundServices()` -- the update scheduler, the `cc-watcher` config watcher, and one-time orphaned-run reconciliation.
|
|
||||||
2. Calls `installHooks()` -- writes the Claude Code hook configuration to `~/.claude/settings.json`, so an install-only user (DMG on macOS, `.exe` on Windows) gets events flowing without ever running `npm run install-hooks` from a checkout.
|
|
||||||
|
|
||||||
It runs only when the server is **owned** by the app -- an adopted server has already done its own bootstrap.
|
|
||||||
|
|
||||||
### App Lifecycle
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
autonumber
|
|
||||||
participant OS as macOS / Windows
|
|
||||||
participant Main as main.ts
|
|
||||||
participant Host as server-host.ts
|
|
||||||
participant Srv as server/index.js
|
|
||||||
participant UI as BrowserWindow
|
|
||||||
|
|
||||||
OS->>Main: launch app
|
|
||||||
Main->>Main: setAppUserModelId (win32) · requestSingleInstanceLock()
|
|
||||||
alt lock not acquired
|
|
||||||
Main->>OS: exit(0) — focus existing instance
|
|
||||||
end
|
|
||||||
Main->>Host: ensureUserPath() — recover login-shell PATH (macOS only)
|
|
||||||
Main->>Host: startEmbeddedServer()
|
|
||||||
Host->>Host: probe :4820 — adopt if a healthy server answers
|
|
||||||
alt no server to adopt
|
|
||||||
Host->>Host: pickFreePort() · set DASHBOARD_DATA_DIR · patch better-sqlite3 ABI
|
|
||||||
Host->>Srv: require() · createApp() · startServer(port)
|
|
||||||
Host->>Srv: waitForHealthy() — poll /api/health ≤ 30s
|
|
||||||
Host->>Srv: bootstrapOwnedServer() — schedulers, cc-watcher, install hooks
|
|
||||||
end
|
|
||||||
Host-->>Main: ServerHandle { url, port, ownedByUs, stop }
|
|
||||||
Main->>Main: installApplicationMenu() · createTray()
|
|
||||||
alt launched at login (--ccam-hidden / wasOpenedAtLogin)
|
|
||||||
Main->>OS: stay tray-only, hide dock (macOS)
|
|
||||||
else normal launch
|
|
||||||
Main->>UI: createDashboardWindow(url)
|
|
||||||
UI->>Srv: GET http://127.0.0.1:port
|
|
||||||
end
|
|
||||||
Note over Main: window "close" → hide (server keeps running)
|
|
||||||
Note over Main: ⌘Q / Ctrl+Q → confirm (second press bypasses)
|
|
||||||
Note over Main: before-quit → stop owned server + closeEmbeddedDatabase()
|
|
||||||
```
|
|
||||||
|
|
||||||
| Event | Behavior |
|
|
||||||
| --- | --- |
|
|
||||||
| **Second launch** | `requestSingleInstanceLock()` (enabled on **every platform**) fails -- the new process exits and the existing window is focused. |
|
|
||||||
| **Window close** | Intercepted -- the window **hides** (`hide()`); the server and tray keep running. |
|
|
||||||
| **`window-all-closed`** | App stays alive in tray-only mode (the handler is intentionally a no-op). |
|
|
||||||
| **Launched at login** | The dashboard window is **not** shown -- only the tray icon. Detected via macOS `wasOpenedAtLogin` (dock hidden, `openAsHidden`) or, on Windows, the `--ccam-hidden` arg written into the `HKCU\…\Run` startup command. |
|
|
||||||
| **Quit shortcut** | ⌘Q (macOS) / Ctrl+Q (Windows) shows a confirmation dialog; a second press bypasses it. |
|
|
||||||
| **`before-quit`** | If the server is owned: stop the HTTP server, then `closeEmbeddedDatabase()` for a clean WAL checkpoint, then `app.exit(0)`. The DB handle is closed here -- never on `Restart Server`, where the cached `server/db.js` singleton must stay usable. |
|
|
||||||
|
|
||||||
### Packaged App Layout
|
|
||||||
|
|
||||||
`electron-builder` produces `Claude Code Monitor.app` on macOS and `Claude Code Monitor.exe` (NSIS installer + portable) on Windows. On both platforms the Electron main-process code is compiled (`tsc` → `out/`) and packed into `app.asar`; the rest of the repo is shipped as **`extraResources`** -- plain files under the bundle's `Resources/app/` (macOS) or the install dir's `resources\app\` (Windows). The internal layout is the same shape on both:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
appbundle["Claude Code Monitor.app (macOS)<br/>Claude Code Monitor install dir (Windows)"]
|
|
||||||
appbundle --> contents["Contents/ (macOS)<br/>install root (Windows)"]
|
|
||||||
contents --> macos["MacOS/ — Electron binary (macOS)<br/>Claude Code Monitor.exe (Windows)"]
|
|
||||||
contents --> res["Resources/ (macOS)<br/>resources\ (Windows)"]
|
|
||||||
res --> asar["app.asar<br/>(compiled out/**, package.json)"]
|
|
||||||
res --> unpacked["app.asar.unpacked/<br/>node_modules/better-sqlite3 (.node)"]
|
|
||||||
res --> appdir["app/"]
|
|
||||||
appdir --> a1["server/ — Express server (no tests)"]
|
|
||||||
appdir --> a2["client/dist/ — built React SPA"]
|
|
||||||
appdir --> a3["scripts/ — hook-handler, install-hooks"]
|
|
||||||
appdir --> a4["node_modules/ — server runtime deps"]
|
|
||||||
appdir --> a5["package.json"]
|
|
||||||
|
|
||||||
style asar fill:#1f6feb,stroke:#1158c7,color:#fff
|
|
||||||
style appdir fill:#238636,stroke:#196c2e,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
At runtime `server-host.ts` resolves this root as `process.resourcesPath/app` when packaged, on both platforms. Everything under the packaged `app/` is **read-only** on a packaged, signed, or app-translocated macOS bundle and on a Windows install under `%ProgramFiles%` (or a portable build's mount) -- so all writable state (the SQLite database, VAPID keys) lives in the per-user data dir (`~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows), **never inside the bundle / install dir** (see [Writable Data Directory](#writable-data-directory)).
|
|
||||||
|
|
||||||
On macOS `electron-builder` produces **two per-architecture DMGs** — one `arm64` (Apple Silicon), one `x64` (Intel) — via `--mac --arm64 --x64` (not a merged universal binary; the release ships both), ad-hoc signed by default so anyone can build a working `.dmg` without a paid Apple Developer account; real Developer ID signing and notarization are opt-in via environment variables (`CSC_LINK`, `APPLE_ID`, etc.). On Windows it produces an **NSIS installer `.exe`** and a **no-install portable `.exe`** (both x64), using `assets/icon.ico` (generated from the source PNG by `desktop/scripts/build-win-icon.ps1`) as the application and tray icon. **`electron-builder` packages for the host OS** -- DMGs build on macOS, Windows `.exe`s build on Windows -- so the two artifacts come from two CI jobs (see below). The `desktop/scripts/prebuild.js` guard also **self-heals** a `better-sqlite3` native binary that a prior cross-arch DMG build (`electron-builder --mac --x64/--arm64`) left compiled for the wrong CPU architecture -- it detects the mismatch via `file` and re-runs `electron-builder install-app-deps`, so `desktop:dev` and `desktop:test` do not fail with `ERR_DLOPEN_FAILED`; on Windows it shells the `.cmd` shims for `npm`/`npx`. CI runs a path-filtered `🍎 macOS Desktop (DMG)` job on `macos-latest` (artifact `ClaudeCodeMonitor-dmg`) and a `🪟 Windows Desktop (EXE)` job on `windows-latest` (artifact `ClaudeCodeMonitor-win`); the release attaches both. See [`desktop/README.md`](./desktop/README.md) for the full build pipeline, build-performance notes, and signing details.
|
|
||||||
|
|
||||||
### Relation to Standalone Deployment
|
|
||||||
|
|
||||||
The desktop app is a fourth deployment mode alongside Development, Production, and Container (see [Deployment Modes](#deployment-modes)). The data path is **identical to the standalone Production path** -- Claude Code hooks `POST /api/hooks/event` to the embedded Express server, which writes to SQLite and broadcasts over WebSocket to the renderer. The only structural difference is that the server runs inside the Electron main process instead of a standalone `node server/index.js`, and the renderer is a `BrowserWindow` rather than a browser tab pointed at the same origin.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
| Area | Approach |
|
| Area | Approach |
|
||||||
@@ -2643,26 +2371,6 @@ graph LR
|
|||||||
| **File watching** | `node --watch` + Vite HMR | None |
|
| **File watching** | `node --watch` + Vite HMR | None |
|
||||||
| **Source maps** | Inline | External files |
|
| **Source maps** | Inline | External files |
|
||||||
|
|
||||||
### Desktop App (macOS & Windows)
|
|
||||||
|
|
||||||
The native desktop app (macOS `.app`/`.dmg`, Windows NSIS / portable `.exe`) is a self-contained deployment mode: a single Electron process embeds the Express server in-process and renders the React client in a `BrowserWindow`. No terminal, no separate `npm start`.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
LAUNCH["Open Claude Code Monitor<br/>(.app / .exe)"] --> MAIN["Electron main process<br/>(Node 22 / Electron 35)"]
|
|
||||||
MAIN --> HOST["server-host.ts<br/>port discovery + adopt"]
|
|
||||||
HOST --> SERVER["server/index.js (in-process)<br/>Port 4820 → fallback"]
|
|
||||||
SERVER -->|serves| DIST["client/dist/<br/>(extraResources)"]
|
|
||||||
MAIN --> WIN["BrowserWindow"]
|
|
||||||
WIN --> SERVER
|
|
||||||
|
|
||||||
style MAIN fill:#1f6feb,stroke:#1158c7,color:#fff
|
|
||||||
style SERVER fill:#339933,stroke:#5cb85c,color:#fff
|
|
||||||
style DIST fill:#646CFF,stroke:#818cf8,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
The hook ingestion path (Claude Code hooks → `POST /api/hooks/event` → SQLite → WebSocket) is **identical to the standalone Production path** -- only the process that hosts the server differs. See [Desktop App Architecture](#desktop-app-architecture-macos--windows--electron) for the full design.
|
|
||||||
|
|
||||||
### MCP Sidecar (Optional)
|
### MCP Sidecar (Optional)
|
||||||
|
|
||||||
The MCP server runs as a sidecar alongside the dashboard, connecting to the same API. It supports three transport modes:
|
The MCP server runs as a sidecar alongside the dashboard, connecting to the same API. It supports three transport modes:
|
||||||
@@ -2748,58 +2456,6 @@ docker run -d -p 4820:4820 \
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **Hook note:** Claude Code hooks run on the host, not inside the container. The containerized server still receives hook events via HTTP on `localhost:4820` — run `npm run install-hooks` on the host after the container is up. `scripts/install-hooks.js` detects container execution and refuses there (issue #193) so it cannot write a container-internal handler path into a bind-mounted host `~/.claude`; the containerized server's boot-time auto-install is skipped for the same reason. Override with `CCAM_ALLOW_CONTAINER_HOOKS=1` only when Claude Code itself runs inside the container.
|
> **Hook note:** Claude Code hooks run on the host, not inside the container. The containerized server still receives hook events via HTTP on `localhost:4820` — run `npm run install-hooks` on the host after the container is up. `scripts/install-hooks.js` detects container execution and refuses there (issue #193) so it cannot write a container-internal handler path into a bind-mounted host `~/.claude`; the containerized server's boot-time auto-install is skipped for the same reason. Override with `CCAM_ALLOW_CONTAINER_HOOKS=1` only when Claude Code itself runs inside the container.
|
||||||
|
|
||||||
### Cloud Deployment
|
|
||||||
|
|
||||||
For production cloud deployments, the `deployments/` directory provides enterprise-grade infrastructure supporting four cloud providers and multiple deployment strategies.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TB
|
|
||||||
subgraph "Deployment Pipeline"
|
|
||||||
direction LR
|
|
||||||
CI["CI Pipeline<br/>Build · Test · Scan"] --> DEPLOY["Deployment<br/>Helm · Kustomize · Terraform"]
|
|
||||||
DEPLOY --> VERIFY["Verification<br/>Health Check · Smoke Tests"]
|
|
||||||
VERIFY -->|Fail| ROLLBACK["Rollback<br/>Instant Revert"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Infrastructure"
|
|
||||||
direction TB
|
|
||||||
subgraph "Compute"
|
|
||||||
BLUE["Blue Slot<br/>Current Version"]
|
|
||||||
GREEN["Green Slot<br/>New Version"]
|
|
||||||
end
|
|
||||||
LB["Load Balancer<br/>TLS 1.3 · WebSocket<br/>Weighted Routing"]
|
|
||||||
PV["Persistent Storage<br/>Encrypted NFS"]
|
|
||||||
MON["Monitoring<br/>Prometheus · Grafana<br/>13 Alert Rules"]
|
|
||||||
OTEL["OTel Collector<br/>Coralogix"]
|
|
||||||
end
|
|
||||||
|
|
||||||
LB -->|"Active"| BLUE
|
|
||||||
LB -.->|"Standby"| GREEN
|
|
||||||
BLUE & GREEN --> PV
|
|
||||||
MON -->|"Scrape"| BLUE & GREEN
|
|
||||||
BLUE & GREEN -->|"logs + metrics + traces"| OTEL
|
|
||||||
|
|
||||||
style BLUE fill:#2563eb,color:#fff
|
|
||||||
style GREEN fill:#16a34a,color:#fff
|
|
||||||
style LB fill:#7c3aed,color:#fff
|
|
||||||
style CI fill:#2088ff,color:#fff
|
|
||||||
style OTEL fill:#4f46e5,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
| Capability | Details |
|
|
||||||
| --- | --- |
|
|
||||||
| **Cloud Providers** | AWS (ECS Fargate + ALB), GCP (Cloud Run + GCLB), Azure (ACI + App Gateway), OCI (OKE + LBaaS) |
|
|
||||||
| **Deployment Methods** | Helm chart, Kustomize overlays, Terraform modules |
|
|
||||||
| **Release Strategies** | Rolling update, blue-green (instant switchover), canary (automated analysis) |
|
|
||||||
| **Environments** | Dev, staging, production with per-environment configuration |
|
|
||||||
| **CI/CD** | GitHub Actions and GitLab CI pipelines with Trivy security scanning |
|
|
||||||
| **Observability** | Prometheus scraping, 13 alert rules, Grafana dashboard (16 panels), Alertmanager routing, Coralogix full-stack observability (logs, metrics, traces, SLO tracking) via OpenTelemetry Collector |
|
|
||||||
| **Operations** | Scripts for deploy, rollback, blue-green switch, database backup/restore, teardown |
|
|
||||||
| **Security** | Restricted PSS, network policies, TLS enforcement, OIDC auth, no long-lived credentials |
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> 📘 **Full guide:** See [DEPLOYMENT.md](DEPLOYMENT.md) for step-by-step deployment instructions, and [deployments/README.md](deployments/README.md) for the infrastructure technical reference.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Statusline Utility
|
## Statusline Utility
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
|
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks).
|
||||||
- `client/`: React + Vite UI.
|
- `client/`: React + Vite UI.
|
||||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`; the dashboard never restarts itself — users run the printed command, surfaced in the UI and by `ccam update-check`.)
|
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`; the dashboard never restarts itself — users run the printed command, surfaced in the UI and by `ccam update-check`.)
|
||||||
- `mcp/`: local MCP server exposing dashboard operations as tools.
|
- `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`.
|
||||||
|
- `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
||||||
|
|
||||||
## Lanes
|
## Lanes
|
||||||
|
|
||||||
@@ -22,7 +23,10 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
|||||||
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`.
|
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`.
|
||||||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
||||||
- **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history.
|
- **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history.
|
||||||
- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence.
|
- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`ccam lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
|
||||||
|
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
|
||||||
|
- **A lane's stack is up or down as a computed fact, never a stored one.** `runtimeFacts` re-derives it from pid files and port probes on every read. A process dies to OOM, a stray `kill`, a reboot — caching a truth CCAM does not control buys ghost state. Slot allocation is the opposite (fully controlled, must be race-free), so that one does live in the DB, under `withLaneLock` plus a partial unique index.
|
||||||
|
- **A profile's `profile.env` is parsed, never sourced.** Hooks are executed deliberately; config is only read. Sourcing arbitrary shell from a user's repository into the dashboard process would be a code-execution path. Hook names always come from the fixed allowlist in `server/lib/lane-profile.js`, never from a request.
|
||||||
- Pipeline templates are JSON files (`server/data/pipelines/`) with node definitions; custom templates override built-ins when `DASHBOARD_PIPELINES_DIR` is set.
|
- Pipeline templates are JSON files (`server/data/pipelines/`) with node definitions; custom templates override built-ins when `DASHBOARD_PIPELINES_DIR` is set.
|
||||||
- Nodes render in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped, amber), `pending` (not reached).
|
- Nodes render in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped, amber), `pending` (not reached).
|
||||||
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
|
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
|
||||||
@@ -46,6 +50,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
|
|||||||
- Server tests: `npm run test:server`
|
- Server tests: `npm run test:server`
|
||||||
- Client tests: `npm run test:client`
|
- Client tests: `npm run test:client`
|
||||||
- MCP install/build/start: `npm run mcp:install`, `npm run mcp:build`, `npm run mcp:start`
|
- MCP install/build/start: `npm run mcp:install`, `npm run mcp:build`, `npm run mcp:start`
|
||||||
|
- MCP build freshness: `npm run mcp:check-build` (must pass whenever `mcp/src` changes)
|
||||||
- MCP typecheck: `npm run mcp:typecheck`
|
- MCP typecheck: `npm run mcp:typecheck`
|
||||||
- CLI (after setup): `ccam <command>` — terminal access to the full dashboard surface (`bin/ccam.js`; `ccam help` lists commands)
|
- CLI (after setup): `ccam <command>` — terminal access to the full dashboard surface (`bin/ccam.js`; `ccam help` lists commands)
|
||||||
|
|
||||||
@@ -68,3 +73,4 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
|
|||||||
- Use file-specific rules in `.claude/rules/` when working in scoped areas.
|
- Use file-specific rules in `.claude/rules/` when working in scoped areas.
|
||||||
- Use project skills from `.claude/skills/` for repeatable workflows.
|
- Use project skills from `.claude/skills/` for repeatable workflows.
|
||||||
- Use `.claude/agents/` subagents for focused review or investigation passes.
|
- Use `.claude/agents/` subagents for focused review or investigation passes.
|
||||||
|
- **Declare lane stage even in plain chat, not just inside skills.** `ccam stage` (see `docs/LANES.md` § Reporting a stage) is a reporting command, not a skill-only ritual — any Claude session working inside an adopted lane's cwd should call it on real stage transitions (starting to plan, starting to implement, running tests, opening the PR, etc.), whether or not a skill is driving. Tool-event detection (`server/lib/stage-detect.js`) only ever paints the amber "detected" badge, never the blue `current` ring — a lane worked entirely through plain chat with no `ccam stage` calls will show a stale `current` stage no matter how much real work happens. Skip it only when `ccam stage` reports no lane owns the cwd (not adopted).
|
||||||
|
|||||||
-1080
File diff suppressed because it is too large
Load Diff
-253
@@ -1,253 +0,0 @@
|
|||||||
# Claude Code Monitor — Desktop App (macOS & Windows)
|
|
||||||
|
|
||||||
The dashboard ships with an optional **native desktop app** (built with Electron 35) that wraps the existing server + client into a single application you install once and forget — a macOS `.app` (shipped as a `.dmg`) and a Windows `.exe` (an NSIS installer plus a no-install portable build). Everything you see in the browser at `localhost:4820` lives inside this window, with native OS lifecycle on top: a menu-bar / notification-area (tray) icon, a native application menu, auto-start at login, and a single quit button that cleans up the server.
|
|
||||||
|
|
||||||
## Why this exists in addition to the PWA
|
|
||||||
|
|
||||||
The PWA (added in #144) makes the dashboard installable in Chromium-based browsers, which is great for users who already keep the server running. The desktop app solves the orthogonal problem: **starting and keeping the server running** without a terminal window. Concretely:
|
|
||||||
|
|
||||||
| Capability | PWA | Desktop App |
|
|
||||||
|---|---|---|
|
|
||||||
| Installs to dock / Applications | ✅ | ✅ |
|
|
||||||
| Manages the Express server | ❌ — user must `npm start` separately | ✅ — embedded in-process |
|
|
||||||
| Auto-starts at login | ❌ | ✅ via macOS Login Items / Windows `HKCU\…\Run` |
|
|
||||||
| Menu-bar / notification-area (tray) icon for always-on status | ❌ | ✅ |
|
|
||||||
| Native application menu (⌘ / Ctrl shortcuts, etc.) | ❌ | ✅ |
|
|
||||||
| Survives browser restart | ⚠️ depends on browser | ✅ |
|
|
||||||
|
|
||||||
The two coexist — install whichever fits your workflow.
|
|
||||||
|
|
||||||
## Quick install
|
|
||||||
|
|
||||||
**Option A — download a pre-built installer** (recommended):
|
|
||||||
|
|
||||||
1. Open [**Releases → latest**](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) and grab the asset for your platform. Every `master` commit that bumps the version in `package.json` cuts a new `vX.Y.Z` release automatically (CI publishes it), so this link always lands on the current build — no GitHub sign-in required.
|
|
||||||
|
|
||||||
| Platform | Asset | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| macOS (Apple Silicon) | `ClaudeCodeMonitor-<ver>-arm64.dmg` | drag into `/Applications` |
|
|
||||||
| macOS (Intel) | `ClaudeCodeMonitor-<ver>-x64.dmg` | drag into `/Applications` |
|
|
||||||
| Windows (installer) | `ClaudeCodeMonitor-Setup-<ver>-x64.exe` | per-user install, no admin |
|
|
||||||
| Windows (portable) | `ClaudeCodeMonitor-<ver>-x64-portable.exe` | run without installing |
|
|
||||||
|
|
||||||
2. Want a **per-commit build** instead of waiting for a release? Every green CI run uploads a workflow artifact (sign-in required, 14-day retention) — `ClaudeCodeMonitor-dmg` from the `🍎 macOS Desktop (DMG)` job and `ClaudeCodeMonitor-win` from the `🪟 Windows Desktop (EXE)` job:
|
|
||||||
```bash
|
|
||||||
gh run download <run-id> -R Smartgift-AI/Claude-Code-Monitor -n ClaudeCodeMonitor-dmg # or ClaudeCodeMonitor-win
|
|
||||||
```
|
|
||||||
3. **macOS:** double-click the DMG → drag `Claude Code Monitor.app` into your `Applications` folder. Open it; macOS may show a Gatekeeper warning the first time — see [Gatekeeper & SmartScreen](#gatekeeper--smartscreen-first-launch) below.
|
|
||||||
4. **Windows:** run `ClaudeCodeMonitor-Setup-<ver>-x64.exe` (per-user, no admin) and follow the wizard, or just run the `*-portable.exe` to launch without installing. Windows **SmartScreen** may show *"Windows protected your PC"* the first time — see [Gatekeeper & SmartScreen](#gatekeeper--smartscreen-first-launch) below.
|
|
||||||
|
|
||||||
**Option B — build locally:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In the project root, after `git clone`:
|
|
||||||
npm run setup # installs root + client + vscode-extension deps
|
|
||||||
npm run build # builds the React client
|
|
||||||
npm run desktop:install # installs Electron + electron-builder
|
|
||||||
|
|
||||||
# Build for macOS (run ON macOS) — pick one:
|
|
||||||
npm run desktop:dmg:arm64 # Apple Silicon only — FAST (~1 min); use this for your own Mac
|
|
||||||
npm run desktop:dmg:x64 # Intel only — FAST
|
|
||||||
npm run desktop:dmg # BOTH per-arch DMGs (arm64 + x64) — the release build; slower (packages each arch)
|
|
||||||
npm run desktop:dmg:universal # ONE merged universal DMG (arm64 + x86_64 in a single file) — optional, slowest
|
|
||||||
|
|
||||||
# Build for Windows (run ON Windows) — pick one:
|
|
||||||
npm run desktop:win # NSIS installer → desktop/release/ClaudeCodeMonitor-Setup-<ver>-x64.exe
|
|
||||||
npm run desktop:win:portable # no-install portable → desktop/release/ClaudeCodeMonitor-<ver>-x64-portable.exe
|
|
||||||
|
|
||||||
# electron-builder packages for the HOST OS — you cannot build a Windows .exe
|
|
||||||
# on macOS or a macOS .dmg on Windows.
|
|
||||||
|
|
||||||
# Open the macOS DMG you just built. desktop:dmg:arm64 / :x64 wipe release/ and emit
|
|
||||||
# one DMG; desktop:dmg wipes release/ and emits both (…-arm64.dmg + …-x64.dmg).
|
|
||||||
open desktop/release/ClaudeCodeMonitor-*-arm64.dmg # …-x64.dmg for the Intel build
|
|
||||||
```
|
|
||||||
|
|
||||||
> **`desktop:dmg` builds both architectures, so it takes longer.** It packages
|
|
||||||
> and ad-hoc-signs the app **twice** — once for `arm64`, once for `x64` — and
|
|
||||||
> emits two separate DMGs (`…-arm64.dmg` + `…-x64.dmg`). It does **not** merge
|
|
||||||
> them into a single universal binary; the release ships the two per-arch DMGs.
|
|
||||||
> For running on **your own Mac**, use the arch-specific command
|
|
||||||
> (`desktop:dmg:arm64` / `desktop:dmg:x64`) — half the work, and it finishes in
|
|
||||||
> about a minute. CI runs `desktop:dmg` for you and uploads both DMGs as the
|
|
||||||
> `ClaudeCodeMonitor-dmg` artifact, so you rarely need to build them locally.
|
|
||||||
|
|
||||||
## What happens when you launch the app
|
|
||||||
|
|
||||||
1. The Electron main process picks a free port — preferring **4820**, falling back to 4821–4829, then a random high port if all those are taken.
|
|
||||||
2. If something already answers `/api/health` on port 4820 (e.g. you ran `npm start` in a terminal), the app **adopts that server** and skips starting a second one. No double-binding, no SQLite contention.
|
|
||||||
3. Otherwise it `require()`s `server/index.js` directly in-process — same Node runtime as the main process, same memory. Boot is typically under two seconds.
|
|
||||||
4. On startup the server records its **live port** to `~/.claude/.agent-dashboard.json`. The Claude Code hook handler reads that file, so events still reach the dashboard when the app bound a fallback port instead of 4820.
|
|
||||||
5. The dashboard window opens — unless the app was launched at login (on macOS via Login Items; on Windows via the `HKCU\…\Run` entry, detected through a `--ccam-hidden` launch arg since Windows has no `wasOpenedAtLogin`), in which case it stays tray-only.
|
|
||||||
6. A tray icon appears — the macOS **menu bar** or the Windows **notification area**. One click opens a dropdown with a **live status snapshot** (server port, active sessions, working agents, events today — all clickable to jump into the dashboard) plus *Open Dashboard*, *Open in Browser*, *Restart Server*, *Show Logs*, *Open at Login* (toggle), and *Quit*.
|
|
||||||
|
|
||||||
## Lifecycle semantics
|
|
||||||
|
|
||||||
- **Closing the window hides it.** The server keeps running, the tray icon stays, and (on macOS) the **dock icon stays too** — clicking either re-opens the window. Independent signals that the app is still alive.
|
|
||||||
- **Quitting** (⌘Q / Ctrl+Q, *Quit* in the application menu, or *Quit* in the tray menu) pops a confirmation dialog — *"Quit Claude Code Monitor? Press ⌘Q again to skip this prompt and quit immediately."* Press **Quit** in the dialog, or **press ⌘Q / Ctrl+Q a second time** to bypass the prompt. Either way the SQLite handle is checkpointed cleanly before the process exits.
|
|
||||||
- **Tray** — the macOS menu bar / Windows notification area. macOS uses a black template glyph the OS tints for light/dark menu bars; Windows uses the colored `icon.ico`, because a template glyph would vanish on the dark taskbar. A single click (left or right) opens the dropdown, which shows a **live status snapshot** pulled straight from the embedded SQLite handle each time it opens: server port, active sessions, working agents, and events today. Snapshot rows are clickable — they open the dashboard. The tray's *Open Dashboard* reliably **raises** the window even when it is minimized or behind other windows. (The application menu's *File ▸ Open Dashboard* / ⌘1 is **macOS-only** — on Windows/Linux a window-attached menu accelerator can't reopen a hidden window, so reopening is the tray's job there.)
|
|
||||||
- **Window / taskbar icon** — the `BrowserWindow` sets its `icon` to the colored app logo (`icon.ico` on Windows, `icon.png` elsewhere — the same logo as the macOS Dock, rendered from `assets/icon.svg`), so an unpackaged `desktop:dev` run shows the real app logo in the title bar / taskbar instead of the generic Electron icon. The macOS dev Dock icon is set too; packaged apps already get theirs from the bundle `.icns`/`.exe`.
|
|
||||||
- **Open-at-login toggle:** flip *Open at Login* in the tray menu (or the app menu). Both platforms go through Electron's first-party `app.*LoginItemSettings` API — no third-party deps. On **macOS** it registers via the `SMAppService` API, so the entry appears under → *System Settings → General → Login Items*. On **Windows** it writes a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry, visible under *Task Manager → Startup*; a login-triggered launch is detected via a `--ccam-hidden` arg (Windows has no `wasOpenedAtLogin`). On Linux the toggle is a no-op (unsupported).
|
|
||||||
- **Single-instance:** double-launching just focuses the existing window. No second server, no port collision. (Applies on every platform.)
|
|
||||||
- **Logs** live at `~/Library/Logs/Claude Code Monitor/desktop.log` on macOS and `%APPDATA%\Claude Code Monitor\logs\desktop.log` on Windows (use *Show Logs* in the tray menu to open the folder).
|
|
||||||
- **Your data** (the SQLite database and VAPID keys) lives outside the app bundle / install dir, so it **survives app reinstalls and updates** — `~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows. The Windows NSIS uninstaller **keeps this data by default** (`deleteAppDataOnUninstall: false`), mirroring how dragging the `.app` to the Trash on macOS never touches your data.
|
|
||||||
- **The `claude` CLI on PATH.** On **macOS** the app resolves it using your login-shell `PATH`, recovered at startup — so "Run Claude" works even though a Finder/Dock-launched app would otherwise only inherit a minimal `PATH`. On **Windows** the inherited user `PATH` already includes it, so no recovery is needed.
|
|
||||||
- **Notifications** (including the in-dashboard *Send test notification* button) are delivered as **native OS toasts** on both platforms when running inside the app — the embedded server calls Electron's `Notification` API directly. On Windows the app sets an `AppUserModelId` (`com.vn.smartgift.ccam.desktop`, matching the electron-builder `appId`) so toasts attribute to the app and its taskbar windows group correctly. Web Push doesn't work reliably inside Electron (Chromium-in-Electron ships without Firebase Cloud Messaging credentials, so `pushManager.subscribe` returns endpoints nothing can deliver to), and this path bypasses it entirely. The web dashboard at `npm start` continues to use Web Push as before.
|
|
||||||
- **Coexists with the web dashboard.** You can run the desktop app and `npm run dev` (or `npm start`) at the same time. Each server writes its `{port, pid, startedAt, dataDir}` entry to a shared discovery file at `~/.claude/.agent-dashboard.json`, and the Claude Code hook handler POSTs to **one ingest target per unique SQLite data directory** (lowest port wins when both share `~/.claude/agent-dashboard`, so events are never double-ingested). Servers with **different** databases (e.g. the desktop app's Application Support dir alongside `npm run dev`) still each receive hooks and stay real-time.
|
|
||||||
|
|
||||||
## File layout (for contributors)
|
|
||||||
|
|
||||||
```
|
|
||||||
desktop/
|
|
||||||
├── package.json # Electron + electron-builder
|
|
||||||
├── tsconfig.json
|
|
||||||
├── electron-builder.yml # macOS (dmg) + Windows (nsis/portable) targets; signing/notarization hooks
|
|
||||||
├── assets/ # icon.svg + generated icon.icns (macOS) + icon.ico (Windows) + tray PNGs
|
|
||||||
├── src/
|
|
||||||
│ ├── main.ts # main process entry, lifecycle; setAppUserModelId on win32
|
|
||||||
│ ├── server-host.ts # in-process Express boot, port discovery, adopt
|
|
||||||
│ ├── window.ts # BrowserWindow + persisted state
|
|
||||||
│ ├── tray.ts # tray icon (platform image: template PNG on macOS, icon.ico on Windows) + context menu
|
|
||||||
│ ├── menu.ts # native application menu
|
|
||||||
│ ├── login-item.ts # open-at-login (macOS Login Items + Windows HKCU\…\Run startup)
|
|
||||||
│ ├── shell-path.ts # recover the user's shell PATH (find `claude`)
|
|
||||||
│ ├── preload.ts # (empty — kept for future renderer bridges)
|
|
||||||
│ ├── logger.ts # file logger
|
|
||||||
│ └── constants.ts # incl. APP_ID (matches electron-builder appId)
|
|
||||||
├── scripts/
|
|
||||||
│ ├── install.js # `desktop:install` wrapper: runs npm install, then prints actionable native-dep help + exits non-zero on failure
|
|
||||||
│ ├── preflight.js # shared native-dep check (hasBetterSqliteBinary) + per-OS prerequisite help (printNativeDepHelp)
|
|
||||||
│ ├── prebuild.js # ensures root + client are built before tsc; shells npm/npx on Windows (.cmd shims); fails fast with setup help when the better-sqlite3 native binary is missing
|
|
||||||
│ ├── build-icons.sh # SVG → PNG/ICNS + tray PNGs via qlmanage/sips/iconutil (macOS)
|
|
||||||
│ ├── build-win-icon.ps1 # icon.png → icon.ico for Windows (PowerShell + .NET)
|
|
||||||
│ └── notarize.js # electron-builder afterSign hook (opt-in; macOS only)
|
|
||||||
└── tests/
|
|
||||||
└── smoke.test.mjs # spawn-and-probe /api/health (resolves the real electron binary via createRequire)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Changes outside `desktop/` are deliberately minimal:**
|
|
||||||
|
|
||||||
- `server/index.js` — a behavior-preserving refactor: the post-listen bootstrap (one-time legacy-session import, update scheduler, Claude Code config watcher, orphaned-run reconciliation) was extracted into an exported `startBackgroundServices()` so the embedded server runs exactly what `node server/index.js` runs. The standalone server path is functionally unchanged. (The legacy-session import previously sat in the standalone-only `require.main` block, so the desktop dashboard started empty — moving it into `startBackgroundServices()` fixes that.) It also now publishes its live port via `server/lib/server-info.js` on startup.
|
|
||||||
- `server/lib/server-info.js` *(new)* — writes/reads the `~/.claude/.agent-dashboard.json` port discovery file.
|
|
||||||
- `scripts/hook-handler.js` — resolves the dashboard port from the discovery file (falling back to `CLAUDE_DASHBOARD_PORT`, then 4820), so hook events reach the server even when it bound a fallback port.
|
|
||||||
|
|
||||||
`client/`, `mcp/`, and `vscode-extension/` are untouched. The Electron main process is otherwise just a host for the same code.
|
|
||||||
|
|
||||||
## Gatekeeper & SmartScreen (first launch)
|
|
||||||
|
|
||||||
### macOS — Gatekeeper
|
|
||||||
|
|
||||||
The DMG is **ad-hoc signed** by default — that's all the project can offer without a paid Apple Developer ID. macOS will warn the first time you open it: *"Apple could not verify…"*.
|
|
||||||
|
|
||||||
Two ways past it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Easiest: strip the quarantine attribute from the DMG before opening.
|
|
||||||
xattr -cr ~/Downloads/ClaudeCodeMonitor-*.dmg
|
|
||||||
```
|
|
||||||
|
|
||||||
Or open → *System Settings → Privacy & Security*, scroll to the blocked DMG, click *Open Anyway*.
|
|
||||||
|
|
||||||
### Windows — SmartScreen
|
|
||||||
|
|
||||||
The Windows `.exe` (both the installer and the portable build) is **unsigned** by default, so Windows **SmartScreen** may show *"Windows protected your PC"* the first time you run it. Click **More info → Run anyway** to launch it.
|
|
||||||
|
|
||||||
Authenticode signing is opt-in for the maintainer: provide a code-signing certificate via `CSC_LINK` (a base64-encoded `.p12`) and `CSC_KEY_PASSWORD` and electron-builder signs the `.exe` automatically — no code change required. A signed build skips the SmartScreen prompt.
|
|
||||||
|
|
||||||
### Notarization (for the maintainer)
|
|
||||||
|
|
||||||
When you're ready to make this go away for everyone, add these three repository secrets:
|
|
||||||
|
|
||||||
| Secret | Where it comes from |
|
|
||||||
|---|---|
|
|
||||||
| `APPLE_ID` | Your Apple ID email |
|
|
||||||
| `APPLE_TEAM_ID` | Your Apple Developer team ID |
|
|
||||||
| `APPLE_APP_SPECIFIC_PASSWORD` | An app-specific password created at appleid.apple.com |
|
|
||||||
|
|
||||||
Optionally, also `CSC_LINK` (base64-encoded `.p12`) and `CSC_KEY_PASSWORD` to provide an explicit Developer ID certificate from outside the runner keychain. The CI workflow picks them up automatically — no code change required. See [`desktop/scripts/notarize.js`](desktop/scripts/notarize.js) for the hook.
|
|
||||||
|
|
||||||
> Local builds are **always ad-hoc signed**: the `package` script sets `CSC_IDENTITY_AUTO_DISCOVERY=false`, so a code-signing certificate already in your macOS keychain is never auto-discovered (an Apple Development cert would otherwise be picked up and fail distribution-type signing). Real signing activates only through the explicit `CSC_LINK` certificate above — that path is unaffected by the flag.
|
|
||||||
|
|
||||||
## Development workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Hot-iterate on the main process (rebuilds tsc on save would be next steps;
|
|
||||||
# v1 ships without watch mode — just re-run desktop:dev after changes):
|
|
||||||
npm run desktop:dev
|
|
||||||
|
|
||||||
# Smoke test (also runs in CI on macOS):
|
|
||||||
npm run desktop:test
|
|
||||||
|
|
||||||
# macOS — single-architecture DMG — fast (~1 min):
|
|
||||||
npm run desktop:dmg:arm64 # or desktop:dmg:x64 for Intel
|
|
||||||
|
|
||||||
# macOS — both per-arch DMGs — slower (builds + signs each architecture):
|
|
||||||
npm run desktop:dmg
|
|
||||||
|
|
||||||
# macOS — one merged universal DMG (arm64 + x86_64 in a single file) — optional, slowest:
|
|
||||||
npm run desktop:dmg:universal
|
|
||||||
|
|
||||||
# Windows — NSIS installer / no-install portable (run ON Windows):
|
|
||||||
npm run desktop:win # NSIS installer .exe
|
|
||||||
npm run desktop:win:portable # no-install portable .exe
|
|
||||||
```
|
|
||||||
|
|
||||||
> electron-builder packages for the **host OS** — build DMGs on macOS and the
|
|
||||||
> Windows `.exe`s on Windows. The Windows icon regenerates from `icon.png` with
|
|
||||||
> `npm run build:win-icon` (PowerShell + .NET); the macOS icns + tray PNGs come
|
|
||||||
> from `npm run build:icons`. On Windows, `better-sqlite3` is fetched as a
|
|
||||||
> prebuilt Electron binary by `npm run desktop:install` (its postinstall runs
|
|
||||||
> `electron-builder install-app-deps`), so no Visual Studio C++ toolchain is
|
|
||||||
> needed in the common case. If that fetch/rebuild *does* fail (no C++ toolchain,
|
|
||||||
> or a Node version with no prebuilt binary), `npm run desktop:install` — and any
|
|
||||||
> `desktop:*` build, gated by `prebuild.js` — prints the exact per-OS fix plus a
|
|
||||||
> no-toolchain alternative and **fails loudly** rather than crashing at runtime:
|
|
||||||
>
|
|
||||||
> ```bash
|
|
||||||
> cd desktop
|
|
||||||
> npm install --ignore-scripts
|
|
||||||
> node node_modules/electron/install.js
|
|
||||||
> npx electron-builder install-app-deps
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> A Node LTS (20/22) ships prebuilt `better-sqlite3` binaries and avoids the
|
|
||||||
> compile entirely.
|
|
||||||
|
|
||||||
> After `npm run clean` in `desktop/`, you must `npm run build` again before
|
|
||||||
> packaging — `clean` removes `out/`, and `electron-builder` only packages, it
|
|
||||||
> does not compile. The `desktop:dmg*` scripts chain the build for you; a bare
|
|
||||||
> `electron-builder` call does not, and fails with
|
|
||||||
> _"entry file out/main.js does not exist"_.
|
|
||||||
|
|
||||||
The smoke test does not exercise the BrowserWindow (no display on headless CI). It spawns Electron, waits for the embedded server to answer `/api/health`, then shuts down. Anything that depends on the renderer is part of the manual QA checklist on the PR.
|
|
||||||
|
|
||||||
## Known caveats
|
|
||||||
|
|
||||||
- **Bundle size** ≈ 80 MB DMG, ≈ 250 MB on disk. The standard Electron tax. The Windows installer is comparable. Tauri would cut this dramatically but at the cost of a sidecar-process model and a Rust toolchain dependency — fair to revisit in a follow-up PR if bundle size becomes a real complaint.
|
|
||||||
- **Native modules**: `better-sqlite3` is rebuilt against Electron's Node version automatically via `electron-builder install-app-deps` in the desktop workspace's `postinstall`. On Windows it is fetched as a **prebuilt Electron binary**, so no Visual Studio C++ toolchain is needed in the common case. If that build *does* fail (or the binary is missing afterward), `npm run desktop:install` — and any `desktop:*` build — prints the exact per-OS fix (Windows: Visual Studio Build Tools with the "Desktop development with C++" workload; macOS: `xcode-select --install`; Linux: build-essential + python3) plus a no-toolchain alternative (`npm install --ignore-scripts` → `node node_modules/electron/install.js` → `npx electron-builder install-app-deps`), and exits non-zero — failing loudly at install/build time rather than crashing at runtime. Even so, if the module is unavailable the server falls back to `node:sqlite` (per #37), so the app still boots.
|
|
||||||
- **Per-architecture DMGs**: `npm run desktop:dmg` builds **both** macOS DMGs (one `arm64`, one `x64`) — the release build, and slower because it packages each architecture separately. It does **not** produce a merged universal binary; the release ships the two per-arch DMGs. `npm run desktop:dmg:arm64` and `npm run desktop:dmg:x64` build a single architecture instead — much faster, and roughly half the disk. If you specifically want a **single merged universal binary** (both slices in one `.dmg`, `lipo`-fat), `npm run desktop:dmg:universal` produces one via `@electron/universal` — the slowest option, and not what the release ships, but handy for hand-distributing one file that runs on any Mac.
|
|
||||||
- **Auto-update**: not wired on either platform. The current update path is *re-download the latest installer* (DMG on macOS, `.exe` on Windows). `electron-updater` + GitHub Releases is the natural follow-up.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Symptom | Cause | Fix |
|
|
||||||
|---|---|---|
|
|
||||||
| "Apple could not verify…" on first launch (macOS) | Unnotarized DMG | `xattr -cr ~/Downloads/ClaudeCodeMonitor-*.dmg` |
|
|
||||||
| "Windows protected your PC" on first launch (Windows) | The `.exe` is unsigned by default (SmartScreen) | Click **More info → Run anyway**. To remove the prompt for everyone, the maintainer can enable Authenticode signing via `CSC_LINK` + `CSC_KEY_PASSWORD` |
|
|
||||||
| macOS prompts to install Rosetta when opening the app | You installed the **x64** build on an Apple Silicon Mac | Check your arch with `uname -m` (`arm64` → Apple Silicon, build with `desktop:dmg:arm64`). The arch-specific `desktop:dmg:arm64` / `desktop:dmg:x64` builds each wipe `release/` and emit a single DMG whose mounted-volume title states the architecture — e.g. *Claude Code Monitor (Apple Silicon)* — so there is no ambiguous window to drag from. (`desktop:dmg` emits both per-arch DMGs at once, for release.) If stale DMGs from an older build linger, clear them with `rm -rf desktop/release` and rebuild |
|
|
||||||
| Window shows but content is blank (macOS) | Server didn't boot — check `~/Library/Logs/Claude Code Monitor/desktop.log` | Restart from tray → *Restart Server* |
|
|
||||||
| Window shows but content is blank (Windows) | Server didn't boot — check `%APPDATA%\Claude Code Monitor\logs\desktop.log` | Restart from tray → *Restart Server* |
|
|
||||||
| Tray icon missing (macOS) | The OS hides tray icons when the menu bar is full | Move other menu-bar items aside, or look in the overflow chevron |
|
|
||||||
| Tray icon missing (Windows) | Windows tucked it into the notification-area overflow | Click the **^** overflow chevron in the taskbar; drag the icon out to keep it pinned |
|
|
||||||
| App didn't auto-start at login (macOS) | Login Items entry got revoked by macOS | Toggle *Open at Login* off and on again from the tray menu |
|
|
||||||
| App didn't auto-start at login (Windows) | The `HKCU\…\Run` startup entry is missing or was disabled | Toggle *Open at Login* off and on again from the tray menu, then confirm the entry under *Task Manager → Startup* is **Enabled** |
|
|
||||||
| `npm run desktop:win` / `:win:portable` fails or produces nothing | electron-builder packages for the host OS — you ran it on macOS/Linux | Build the Windows `.exe` **on Windows** (and DMGs on macOS) |
|
|
||||||
| Desktop build/install fails on `better-sqlite3` / native binary missing | No C++ toolchain, or no prebuilt for your Node version | Run `npm run desktop:install` and follow the printed help, or use the no-toolchain alternative (`npm install --ignore-scripts` → `node node_modules/electron/install.js` → `npx electron-builder install-app-deps`); or use Node LTS 20/22 |
|
|
||||||
| Port 4820 already in use, app refuses to start | Something other than the dashboard is on 4820 and it doesn't answer `/api/health` | The app will pick a fallback (4821–4829, then a random high port) — check the tray menu's port indicator |
|
|
||||||
| Dashboard stays empty — 0 sessions, 0 agents, no real-time updates | The app bound a fallback port (4820 was taken), and the Claude Code hooks were posting events to the wrong port | Fixed — the server publishes its live port to `~/.claude/.agent-dashboard.json` and the hook handler reads it. After upgrading from a pre-fix build, **start a new Claude Code session** so the updated hooks take effect |
|
|
||||||
| `desktop:dmg` seems slow | Not stuck — it packages two architectures back-to-back (`arch=x64` then `arch=arm64`) | Wait it out, or build a single architecture with `desktop:dmg:arm64` / `desktop:dmg:x64` |
|
|
||||||
| Build fails: `entry file out/main.js does not exist` | `electron-builder` was run without compiling TypeScript first | Build via `npm run desktop:dmg*` (chains the build); don't invoke `electron-builder` bare |
|
|
||||||
| Signing fails with `Application … could not be found` | A code-signing certificate in your keychain was auto-discovered | Fixed — the `package` script sets `CSC_IDENTITY_AUTO_DISCOVERY=false`; build via `npm run desktop:dmg*` |
|
|
||||||
| "Run Claude" reports the `claude` CLI isn't on your PATH | A Finder/Dock-launched app inherits launchd's minimal PATH, not your shell PATH | Fixed — the app recovers your login-shell PATH at startup. If it persists, ensure `claude` is a real executable (not a shell alias/function) and on your shell PATH |
|
|
||||||
| Imported history / sessions vanished after updating the app | Older builds stored the database inside the (replaceable) app bundle | Fixed — data now lives in `~/Library/Application Support/Claude Code Monitor/data/` and survives reinstalls. After upgrading from a pre-fix build, re-run **Import History → Rescan** once |
|
|
||||||
| Signing fails: `Application … could not be found` after retries | A keychain code-signing certificate was auto-discovered | Fixed — the `package` script sets `CSC_IDENTITY_AUTO_DISCOVERY=false`; build via `npm run desktop:dmg*` |
|
|
||||||
@@ -35,6 +35,10 @@ RUN npm run build
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Runs Claude Code sessions inside a named tmux session per lane so both the
|
||||||
|
# dashboard (via node-pty attach) and a real terminal can share one live pane.
|
||||||
|
RUN apk add --no-cache tmux
|
||||||
|
|
||||||
COPY --from=server-deps /app/node_modules ./node_modules/
|
COPY --from=server-deps /app/node_modules ./node_modules/
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
|
|||||||
+22
-140
@@ -1,6 +1,27 @@
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
A step-by-step guide to get the Claude Code Agent Monitor up and running on your machine, with optional sections for importing history, running in a container, and using the native desktop app (macOS & Windows).
|
A step-by-step guide to get the Claude Code Agent Monitor up and running on your machine, with optional sections for importing history and running in a container.
|
||||||
|
|
||||||
|
## Fastest path — install it as a Claude Code plugin
|
||||||
|
|
||||||
|
If all you want is a working dashboard, skip every step below. From Claude Code:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||||
|
/plugin install ccam@claude-code-agent-monitor-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
The next session start installs the hooks, boots the server, puts the `ccam` CLI
|
||||||
|
on PATH and connects the MCP tools — no clone, no `npm run setup`, no
|
||||||
|
`npm run install-hooks`, no manual `npm start`. Run `/ccam-doctor` to check the
|
||||||
|
result and `/ccam-open` to build the UI and get the URL.
|
||||||
|
|
||||||
|
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
||||||
|
the server stores data through `node:sqlite`). Full behavior, including the
|
||||||
|
uninstall cleanup, is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||||
|
|
||||||
|
Follow the steps below instead when you want a checkout — to develop the
|
||||||
|
dashboard, run it from source, or stay on Node 20.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -168,122 +189,6 @@ Open **http://localhost:4820** in your browser.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Desktop App (macOS & Windows) (optional)
|
|
||||||
|
|
||||||
If you'd rather not keep a terminal window open, the project also ships an Electron 35-based **native desktop app** (the `desktop/` workspace), available for both **macOS** and **Windows**. It embeds the Express server in-process, renders the built React client in a `BrowserWindow`, registers a menu-bar / notification-area (tray) icon, and offers a one-click "Open at Login" toggle. Everything you'd see in the browser at `localhost:4820` lives inside a single app you install once — distributed as a macOS `.app` (in a `.dmg`) and a Windows `.exe` (an NSIS installer plus a no-install portable build).
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
| For… | You need |
|
|
||||||
|---|---|
|
|
||||||
| Downloading a pre-built installer (macOS) | macOS — nothing else |
|
|
||||||
| Downloading a pre-built installer (Windows) | Windows 10/11 (x64) — nothing else |
|
|
||||||
| Building the DMG locally (macOS) | macOS, Node.js 20+ (22+ recommended), npm 9+, and **Xcode command-line tools** (`xcode-select --install`) so the native `better-sqlite3` module can be rebuilt for Electron's ABI |
|
|
||||||
| Building the `.exe` locally (Windows) | Windows, Node.js 20+ (22+ recommended), npm 9+. `better-sqlite3` is fetched as a **prebuilt Electron binary** by `npm run desktop:install`, so no Visual Studio C++ toolchain is needed in the common case. If the build _does_ fail, `npm run desktop:install` prints the exact fix (Visual Studio Build Tools + "Desktop development with C++") plus a no-toolchain alternative and exits non-zero rather than failing silently |
|
|
||||||
|
|
||||||
### Way 1 — Download a pre-built installer
|
|
||||||
|
|
||||||
The fastest path. There are two flavours:
|
|
||||||
|
|
||||||
**1a. From the latest GitHub Release** *(recommended — public, no sign-in)*
|
|
||||||
|
|
||||||
Open [**Releases → latest**](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) and download the asset for your platform. CI publishes a new `vX.Y.Z` release automatically every time the version in `package.json` is bumped on `master`, so this link always points at the current shipping build.
|
|
||||||
|
|
||||||
| Platform | Asset | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| macOS (Apple Silicon) | `ClaudeCodeMonitor-<ver>-arm64.dmg` | drag into `/Applications` |
|
|
||||||
| macOS (Intel) | `ClaudeCodeMonitor-<ver>-x64.dmg` | drag into `/Applications` |
|
|
||||||
| Windows (installer) | `ClaudeCodeMonitor-Setup-<ver>-x64.exe` | per-user install, no admin |
|
|
||||||
| Windows (portable) | `ClaudeCodeMonitor-<ver>-x64-portable.exe` | run without installing |
|
|
||||||
|
|
||||||
**1b. From the per-commit CI artifact** *(useful for testing master before it's tagged — sign-in required, 14-day retention)*
|
|
||||||
|
|
||||||
Every green run of the desktop CI jobs uploads a packaged artifact — `ClaudeCodeMonitor-dmg` from the `🍎 macOS Desktop (DMG)` job and `ClaudeCodeMonitor-win` from the `🪟 Windows Desktop (EXE)` job:
|
|
||||||
|
|
||||||
- **Via the GitHub UI:** open the latest passing run under [Actions](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/actions/workflows/ci.yml?query=branch%3Amaster+is%3Asuccess), scroll to **Artifacts**, and download `ClaudeCodeMonitor-dmg` (macOS) or `ClaudeCodeMonitor-win` (Windows).
|
|
||||||
- **Via the `gh` CLI:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh run download <run-id> -R Smartgift-AI/Claude-Code-Monitor -n ClaudeCodeMonitor-dmg # macOS
|
|
||||||
gh run download <run-id> -R Smartgift-AI/Claude-Code-Monitor -n ClaudeCodeMonitor-win # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
Unzip the macOS artifact to get the `.dmg`s, or the Windows artifact to get the NSIS installer + portable `.exe`s.
|
|
||||||
|
|
||||||
Then jump to [Install the app](#install-the-app).
|
|
||||||
|
|
||||||
### Way 2 — Build the installer locally
|
|
||||||
|
|
||||||
From the project root, after `git clone`. electron-builder packages for the **host OS**, so build the macOS DMG on a Mac and the Windows `.exe` on Windows. The common prelude is the same:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run setup # install root + client + vscode-extension deps
|
|
||||||
npm run build # build the React client (the SPA the window loads)
|
|
||||||
npm run desktop:install # install Electron + electron-builder into desktop/
|
|
||||||
|
|
||||||
# macOS (run on macOS):
|
|
||||||
npm run desktop:dmg:arm64 # fast single-arch DMG → desktop/release/
|
|
||||||
|
|
||||||
# Windows (run on Windows):
|
|
||||||
npm run desktop:win # NSIS installer .exe → desktop/release/
|
|
||||||
```
|
|
||||||
|
|
||||||
The artifact lands in `desktop/release/`. Pick the build command that matches your goal:
|
|
||||||
|
|
||||||
| Command | Platform / Architecture | Speed | Use when |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `npm run desktop:dmg` | macOS — both per-arch DMGs (arm64 + x64) | **Slower** | Building the release DMGs for everyone |
|
|
||||||
| `npm run desktop:dmg:arm64` | macOS — Apple Silicon only | Fast (~1 min) | Building for your own Apple Silicon Mac |
|
|
||||||
| `npm run desktop:dmg:x64` | macOS — Intel only | Fast (~1 min) | Building for your own Intel Mac |
|
|
||||||
| `npm run desktop:dmg:universal` | macOS — one merged universal DMG (arm64 + x86_64) | **Slowest** | Hand-distributing a single file that runs on any Mac (not what the release ships) |
|
|
||||||
| `npm run desktop:win` | Windows — NSIS installer `.exe` (x64) | — | Building the per-user installer |
|
|
||||||
| `npm run desktop:win:portable` | Windows — portable `.exe` (x64) | — | Building the no-install portable build |
|
|
||||||
| `npm run desktop:install` | — | — | Install Electron + electron-builder deps; preflights the native `better-sqlite3` build and prints actionable setup help on failure |
|
|
||||||
| `npm run desktop:build` | — | — | TypeScript compile only (`out/`) |
|
|
||||||
| `npm run desktop:dev` | — | — | Build, then launch Electron locally |
|
|
||||||
| `npm run desktop:test` | — | — | Smoke test (spawn Electron, probe `/api/health`) |
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> **DMGs build on macOS; Windows `.exe`s build on Windows** — electron-builder packages for the host OS. On macOS, `npm run desktop:dmg` builds the app **twice** (one tree per architecture) and emits **both** per-arch DMGs (`arm64` + `x64`) — the release build. It does **not** merge them into a single universal binary; the two DMGs are what ship. **When building for your own Mac, use `desktop:dmg:arm64` or `desktop:dmg:x64`** — a single architecture finishes in roughly a minute. CI already builds both DMGs and the Windows `.exe`s for you (see Way 1).
|
|
||||||
|
|
||||||
### Install the app
|
|
||||||
|
|
||||||
**macOS.** Each `desktop:dmg*` build wipes `release/` first. `desktop:dmg:arm64`
|
|
||||||
→ `…-arm64.dmg` and `desktop:dmg:x64` → `…-x64.dmg` each emit a single DMG whose
|
|
||||||
mounted-volume title states the architecture (e.g. *Claude Code Monitor (Apple
|
|
||||||
Silicon)*); `desktop:dmg` emits **both** (`…-arm64.dmg` + `…-x64.dmg`) for
|
|
||||||
release. Install the one matching your Mac: an x64 build on Apple Silicon makes
|
|
||||||
macOS prompt for Rosetta.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
open desktop/release/ClaudeCodeMonitor-*-arm64.dmg # the arch you built
|
|
||||||
```
|
|
||||||
|
|
||||||
1. The DMG mounts — drag **Claude Code Monitor** into your `Applications` folder.
|
|
||||||
2. The DMG is ad-hoc signed, so macOS Gatekeeper shows a warning (*"Apple could not verify…"*) on first launch. Strip the quarantine attribute, then open it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xattr -cr "/Applications/Claude Code Monitor.app"
|
|
||||||
open "/Applications/Claude Code Monitor.app"
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, open → *System Settings → Privacy & Security* and click *Open Anyway*.
|
|
||||||
|
|
||||||
**Windows.**
|
|
||||||
|
|
||||||
1. Run `ClaudeCodeMonitor-Setup-<ver>-x64.exe`. It installs **per-user** (no administrator elevation) and lets you pick the install directory — or run the `*-portable.exe` to launch without installing.
|
|
||||||
2. The installer is **unsigned** by default, so Windows **SmartScreen** may show *"Windows protected your PC"* on first launch — click **More info → Run anyway**.
|
|
||||||
3. Launch from the Start menu / desktop shortcut.
|
|
||||||
|
|
||||||
Once running, the embedded server boots on port `4820` (or adopts an already-healthy server on `4820`, or falls back to `4821`–`4829` / a random high port), the menu-bar / notification-area (tray) icon appears, and the dashboard window opens. **Hooks are installed automatically on first boot** — an install-only user does not need `npm run install-hooks`; just start a new Claude Code session. Closing the window hides it but keeps the server running; **Quit** from the tray exits.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The packaged app stores its SQLite database and VAPID keys in a per-user app-data directory **outside** the app bundle / install dir — `~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows. Your imported history and events therefore **survive app reinstalls and updates** (the Windows NSIS uninstaller keeps this data by default). (Older macOS builds kept the database inside the bundle, which is read-only once installed and code-signed — that broke History Import; it is now fixed. If you are upgrading from a pre-fix build, there is a one-time data gap: re-run **Settings → Import History → Rescan** once.)
|
|
||||||
|
|
||||||
Full user guide: [`DESKTOP.md`](DESKTOP.md). Contributor / architecture reference: [`desktop/README.md`](desktop/README.md). Desktop-specific setup details (logs, auto-start, port adoption) are in [SETUP.md → Desktop App Setup](./SETUP.md#desktop-app-setup).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Optional: Local MCP server
|
## Optional: Local MCP server
|
||||||
|
|
||||||
If you want AI agents to call dashboard functionality through MCP tools, run the local MCP server in `mcp/`:
|
If you want AI agents to call dashboard functionality through MCP tools, run the local MCP server in `mcp/`:
|
||||||
@@ -448,17 +353,6 @@ If you see an error box at startup saying *"SQLite backend not available"*, eith
|
|||||||
|
|
||||||
Then run: `npm rebuild better-sqlite3`
|
Then run: `npm rebuild better-sqlite3`
|
||||||
|
|
||||||
### Desktop build or install fails on the native dependency
|
|
||||||
|
|
||||||
Unlike the root server (which falls back to `node:sqlite`), the desktop app **requires** `better-sqlite3` built for Electron's ABI. If that build can't happen, `npm run desktop:install` (and the desktop `prebuild` gate that runs before every `desktop:*` build) now stops with copy-pasteable setup help instead of a raw node-gyp trace or a runtime crash: it lists the per-OS C++ toolchain prerequisites (Windows: Visual Studio Build Tools + "Desktop development with C++"; macOS: `xcode-select --install`; Linux: build-essential + python3), notes that Node LTS 20/22 ship prebuilt binaries, and offers a no-toolchain alternative:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd desktop
|
|
||||||
npm install --ignore-scripts
|
|
||||||
node node_modules/electron/install.js
|
|
||||||
npx electron-builder install-app-deps
|
|
||||||
```
|
|
||||||
|
|
||||||
### `npm run dev` fails immediately
|
### `npm run dev` fails immediately
|
||||||
|
|
||||||
Ensure both server and client dependencies are installed:
|
Ensure both server and client dependencies are installed:
|
||||||
@@ -482,18 +376,6 @@ The Vite dev server and Express server run on different ports. Make sure both ar
|
|||||||
|
|
||||||
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
|
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
|
||||||
|
|
||||||
### Desktop App (macOS & Windows) issues
|
|
||||||
|
|
||||||
| Symptom | Cause | Fix |
|
|
||||||
|---|---|---|
|
|
||||||
| *"Apple could not verify…"* on first launch (macOS) | The DMG is ad-hoc signed (no paid Apple Developer ID) | `xattr -cr "/Applications/Claude Code Monitor.app"`, then open it — or use *System Settings → Privacy & Security → Open Anyway* |
|
|
||||||
| *"Windows protected your PC"* on first launch (Windows) | The `.exe` is unsigned by default, so SmartScreen prompts | Click **More info → Run anyway** |
|
|
||||||
| `npm run desktop:dmg` seems slow (macOS) | Not hung — it packages two architectures back-to-back (`arch=x64` then `arch=arm64`) | Wait it out, or use `npm run desktop:dmg:arm64` / `npm run desktop:dmg:x64` for a fast single-arch build |
|
|
||||||
| `entry file out/main.js does not exist` | `npm run clean` (in `desktop/`) deleted `out/`; `electron-builder` only packages, it does not compile | Re-run `npm run desktop:build` (or just use a `desktop:dmg*` / `desktop:win*` script, which chains the build) |
|
|
||||||
| Desktop window opens but is blank | The embedded server failed `/api/health` within 30 s | Check the desktop log (`~/Library/Logs/Claude Code Monitor/desktop.log` on macOS, `%APPDATA%\Claude Code Monitor\logs\desktop.log` on Windows), then tray → *Restart Server* |
|
|
||||||
| "Run Claude" says `claude` is not on your PATH | A Finder/Dock-launched macOS app only inherits launchd's minimal PATH, not your login-shell PATH (on Windows the process already inherits the user PATH) | The app recovers your login-shell PATH at startup so it can find and spawn the `claude` CLI. If it still fails, make sure `claude` is a real executable on your shell PATH — not a shell alias or function |
|
|
||||||
| Imported history vanished after updating the app | Older builds stored the database inside the (replaceable) `.app` bundle | Fixed — data now lives in the per-user app-data dir (`~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows) and survives reinstalls/updates. After upgrading from a pre-fix build, re-run **Settings → Import History → Rescan** once |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ tool call to an Express + SQLite server, a React UI updates over WebSocket, and
|
|||||||
|
|
||||||
Internal build — all rights reserved.
|
Internal build — all rights reserved.
|
||||||
|
|
||||||
|
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
||||||
@@ -15,8 +17,10 @@ Internal build — all rights reserved.
|
|||||||
- **Stage detection.** The stage is inferred from the tool stream, so a session
|
- **Stage detection.** The stage is inferred from the tool stream, so a session
|
||||||
that never calls `ccam stage` still shows progress — rendered dashed amber and
|
that never calls `ccam stage` still shows progress — rendered dashed amber and
|
||||||
never as done, because an inference is not evidence.
|
never as done, because an inference is not evidence.
|
||||||
- **Run Claude from the browser.** Spawn a session in a lane's directory, stream
|
- **Run Claude from the browser.** A real terminal (tmux + a real PTY,
|
||||||
its output, send follow-ups, resume any past session.
|
rendered with xterm.js) attached to a lane's directory — the exact TUI you'd
|
||||||
|
see locally, fully interactive, resumable, and attachable from a real
|
||||||
|
terminal too via `ccam lanes shell`.
|
||||||
- **Analytics, alerts, Kanban and a workflow view**, plus an MCP server and a CLI.
|
- **Analytics, alerts, Kanban and a workflow view**, plus an MCP server and a CLI.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -26,7 +30,25 @@ are verified on — node 25 currently breaks 6 server tests through a
|
|||||||
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
||||||
change.
|
change.
|
||||||
|
|
||||||
## Install and run
|
## Install as a Claude Code plugin
|
||||||
|
|
||||||
|
Two commands on a machine that has nothing but Claude Code, no clone and no
|
||||||
|
`npm run setup`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||||
|
/plugin install ccam@claude-code-agent-monitor-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
The first session start installs the hooks, boots the server, puts `ccam` on
|
||||||
|
PATH and connects the MCP tools; it runs detached, so the session never waits on
|
||||||
|
it. `/ccam-doctor` reports the state, `/ccam-open` builds the UI and prints the
|
||||||
|
URL, `/ccam-update` refreshes after a plugin update. This path needs Node
|
||||||
|
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
|
||||||
|
Details, including what to delete on uninstall:
|
||||||
|
[`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||||
|
|
||||||
|
## Install from a checkout
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run setup # root, client and vscode-extension dependencies
|
npm run setup # root, client and vscode-extension dependencies
|
||||||
@@ -42,8 +64,11 @@ Development, with hot reload:
|
|||||||
npm run dev # server on :4820, Vite client on :5173
|
npm run dev # server on :4820, Vite client on :5173
|
||||||
```
|
```
|
||||||
|
|
||||||
`DASHBOARD_PORT` overrides the port. `postinstall` writes the Claude Code hook
|
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
|
||||||
entries that feed the dashboard.
|
built UI is served from (defaults to `client/dist`; the plugin install points it
|
||||||
|
at its own runtime directory). `postinstall` writes the Claude Code hook entries
|
||||||
|
that feed the dashboard — do not run it when the `ccam` plugin is installed, or
|
||||||
|
every event is counted twice.
|
||||||
|
|
||||||
## The CLI
|
## The CLI
|
||||||
|
|
||||||
@@ -54,6 +79,7 @@ ccam status # is the dashboard up
|
|||||||
ccam start # start it in the background and wait for healthy
|
ccam start # start it in the background and wait for healthy
|
||||||
ccam sessions # recent sessions
|
ccam sessions # recent sessions
|
||||||
ccam lanes # lanes with stage and progress
|
ccam lanes # lanes with stage and progress
|
||||||
|
ccam lanes pipeline # this lane's pipeline template, or switch it
|
||||||
ccam stage <name> # declare the current lane's stage
|
ccam stage <name> # declare the current lane's stage
|
||||||
ccam tail # live event feed
|
ccam tail # live event feed
|
||||||
```
|
```
|
||||||
@@ -80,8 +106,31 @@ events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
|
|||||||
move backwards between work sessions. Detection never writes the declared stage,
|
move backwards between work sessions. Detection never writes the declared stage,
|
||||||
and an inferred node never renders as done.
|
and an inferred node never renders as done.
|
||||||
|
|
||||||
|
A lane's pipeline template can be switched after creation — `ccam lanes
|
||||||
|
pipeline <template>` from the terminal, or the pipeline-template picker next to
|
||||||
|
the lane's title in the Workspace detail panel. Both re-resolve the lane's
|
||||||
|
current declared stage against the new template's nodes and warn if it no
|
||||||
|
longer matches one.
|
||||||
|
|
||||||
|
A lane can also run **its own application stack**, isolated per lane, when its
|
||||||
|
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
|
||||||
|
ports and per-lane directories derive from it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccam lanes up # boot the stack of the lane owning this directory
|
||||||
|
ccam lanes runtime # slot, ports, service health
|
||||||
|
ccam lanes logs api # tail a service log
|
||||||
|
ccam lanes down
|
||||||
|
```
|
||||||
|
|
||||||
|
Services are fully detached, so restarting the dashboard never stops a running
|
||||||
|
lane. This is resource namespacing on the host, not a container: lanes run as the
|
||||||
|
same user and share the network.
|
||||||
|
|
||||||
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
||||||
preflight contract, the Workspace page, and `GET /api/lanes/:id/git`.
|
preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full
|
||||||
|
runtime/profile contract.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -100,8 +149,7 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
|||||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
| `client/` | React 18 + Vite + Tailwind dashboard |
|
||||||
| `bin/ccam.js` | CLI |
|
| `bin/ccam.js` | CLI |
|
||||||
| `mcp/` | MCP server exposing read-only dashboard tools |
|
| `mcp/` | MCP server exposing read-only dashboard tools |
|
||||||
| `desktop/` | Electron wrapper that embeds the server |
|
| `docs/` | Architecture, API, lanes, database |
|
||||||
| `docs/` | Architecture, API, lanes, database, deployment |
|
|
||||||
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
@@ -110,5 +158,5 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
|||||||
- [`docs/LANES.md`](docs/LANES.md) — lanes, pipelines, stage detection
|
- [`docs/LANES.md`](docs/LANES.md) — lanes, pipelines, stage detection
|
||||||
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
|
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
|
||||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
|
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
|
||||||
- [`INSTALL.md`](INSTALL.md) · [`DEPLOYMENT.md`](DEPLOYMENT.md) · [`DESKTOP.md`](DESKTOP.md)
|
- [`INSTALL.md`](INSTALL.md)
|
||||||
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
|
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
|
||||||
|
|||||||
+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
|
||||||
@@ -98,6 +98,7 @@ Container-specific behavior:
|
|||||||
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
|
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
|
||||||
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
|
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
|
||||||
| `NODE_ENV` | `development` | Set to `production` to serve built client |
|
| `NODE_ENV` | `development` | Set to `production` to serve built client |
|
||||||
|
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `ccam` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
|
||||||
| `CCAM_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
|
| `CCAM_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
|
||||||
| `CCAM_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
| `CCAM_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
||||||
| `CCAM_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
| `CCAM_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
||||||
@@ -117,7 +118,7 @@ DASHBOARD_PORT=9000 npm run dev
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agent-dashboard.json` so the Claude Code hook handler discovers it without any env var.
|
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agent-dashboard.json` so the Claude Code hook handler discovers it without any env var.
|
||||||
>
|
>
|
||||||
> Multiple dashboards can run side by side — for example `npm run dev` and the desktop app (macOS or Windows) at the same time. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
|
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
|
||||||
>
|
>
|
||||||
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
|
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
|
||||||
>
|
>
|
||||||
@@ -216,59 +217,6 @@ The dashboard, landing page, and wiki each ship as independent Progressive Web A
|
|||||||
|
|
||||||
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
|
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
|
||||||
|
|
||||||
### Desktop App Setup
|
|
||||||
|
|
||||||
The `desktop/` workspace ships the dashboard as a **native desktop app** for both **macOS** (a `.app` distributed as a `.dmg`) and **Windows** (an `.exe` — an NSIS installer plus a no-install portable build), built with Electron 35. It is an Electron shell that **embeds the existing Express server in-process** — it does not reimplement anything. For installation (download a pre-built installer from the [latest GitHub Release](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) or the per-commit `ClaudeCodeMonitor-dmg` / `ClaudeCodeMonitor-win` CI artifact, or build one locally — then on macOS mount, drag, Gatekeeper bypass; on Windows run the installer / portable, SmartScreen bypass), see [INSTALL.md → Desktop App (macOS & Windows)](./INSTALL.md#desktop-app-macos--windows-optional). The full user guide is [`DESKTOP.md`](./DESKTOP.md); the contributor / architecture reference is [`desktop/README.md`](./desktop/README.md).
|
|
||||||
|
|
||||||
This section covers the parts of running the desktop app that matter for setup.
|
|
||||||
|
|
||||||
**Building and running.** All commands run from the repo root. electron-builder packages for the **host OS** — build the macOS DMG on a Mac (`desktop:dmg*`) and the Windows `.exe` on Windows (`desktop:win*`):
|
|
||||||
|
|
||||||
| Script | Command | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `desktop:install` | `npm run desktop:install` | Install Electron + electron-builder into `desktop/`; fetches `better-sqlite3` as a prebuilt Electron binary for Electron's ABI (no Visual Studio C++ toolchain needed in the common case; on macOS, Xcode CLI tools cover any fallback build). Preflights the native `better-sqlite3` build; on failure prints actionable per-OS setup help plus a no-toolchain alternative and exits non-zero (also enforced by the desktop `prebuild` gate) |
|
|
||||||
| `desktop:build` | `npm run desktop:build` | Prebuild guard + `tsc` → `desktop/out/` |
|
|
||||||
| `desktop:dev` | `npm run desktop:dev` | Build, then launch Electron against `out/main.js` |
|
|
||||||
| `desktop:test` | `npm run desktop:test` | Build, then run the smoke test (spawn Electron, probe `/api/health`) |
|
|
||||||
| `desktop:dmg` | `npm run desktop:dmg` | **macOS** — **both** per-arch DMGs (arm64 + x64) → `desktop/release/`. Correct for release. **Slower** (packages each arch). |
|
|
||||||
| `desktop:dmg:arm64` | `npm run desktop:dmg:arm64` | **macOS** — Apple-Silicon-only DMG → `desktop/release/`. **Fast (~1 min).** |
|
|
||||||
| `desktop:dmg:x64` | `npm run desktop:dmg:x64` | **macOS** — Intel-only DMG → `desktop/release/`. **Fast (~1 min).** |
|
|
||||||
| `desktop:win` | `npm run desktop:win` | **Windows** — NSIS installer `.exe` (x64) → `desktop/release/`. |
|
|
||||||
| `desktop:win:portable` | `npm run desktop:win:portable` | **Windows** — no-install portable `.exe` (x64) → `desktop/release/`. |
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Every `desktop:dmg*` / `desktop:win*` script chains `npm run build` first. Running `electron-builder` bare skips the TypeScript compile and fails with `entry file out/main.js does not exist`. `npm run clean` inside `desktop/` deletes `out/` and `release/` — after a clean you must `npm run desktop:build` again before packaging.
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> On macOS, building a DMG rebuilds the native `better-sqlite3` module for the **target** architecture, which can leave it built for the wrong CPU arch for your local machine. The desktop `prebuild` step auto-heals this — it rebuilds `better-sqlite3` for the local machine on the next `desktop:build` — so `npm run desktop:dev` and `npm run desktop:test` keep working after a cross-arch DMG build with no manual `npm run desktop:install` needed.
|
|
||||||
|
|
||||||
**Hooks are auto-installed by the app.** On its first **owned-server** boot the desktop app writes the Claude Code hook configuration to `~/.claude/settings.json` itself, then starts the background services (update scheduler, `cc-watcher` config watcher, orphaned-run reconciliation) — the same `startBackgroundServices()` that `node server/index.js` runs. An install-only user (macOS or Windows) therefore never needs `npm run install-hooks` from a checkout: just **start a new Claude Code session** after the app is running. (If the app *adopts* an existing server instead of starting its own, that server already did its own hook bootstrap — see port adoption below.)
|
|
||||||
|
|
||||||
**Port-adoption behavior.** When the desktop app launches, its embedded server picks a port:
|
|
||||||
|
|
||||||
1. It prefers **`4820`**.
|
|
||||||
2. If a healthy dashboard server already answers `GET /api/health` on `4820` (for example you ran `npm start` in a terminal), the app **adopts that server** instead of double-binding — no SQLite contention. An adopted server is *not* owned by the app, so quitting the app leaves it running.
|
|
||||||
3. Otherwise it falls back to `4821`–`4829`, then to a random high port (`49152`–`49500`).
|
|
||||||
|
|
||||||
The chosen port is shown in the tray menu. The embedded server also honors the dashboard env vars in [Environment variables](#environment-variables) (`DASHBOARD_PORT` is set automatically by the desktop host).
|
|
||||||
|
|
||||||
**Data directory.** The packaged app stores its SQLite database and VAPID keys in a per-user app-data directory — `~/Library/Application Support/Claude Code Monitor/data/` on macOS, `%APPDATA%\Claude Code Monitor\data\` on Windows — **outside** the app bundle / install dir. The desktop host sets `DASHBOARD_DATA_DIR` to this per-user location automatically. Keeping writable state out of the bundle means a packaged, code-signed (and therefore read-only) `.app` never tries to write inside itself, and your imported history and events **survive app reinstalls and updates** (the Windows NSIS uninstaller keeps this data by default). (Older macOS builds kept the database inside the bundle, which broke History Import; after upgrading from a pre-fix build, re-run **Settings → Import History → Rescan** once to close the one-time data gap.)
|
|
||||||
|
|
||||||
**`claude` CLI resolution.** A Finder/Dock-launched macOS app inherits only launchd's minimal `PATH`, not your login-shell `PATH`. So the app can find and spawn the `claude` CLI for the "Run Claude" feature, the desktop host recovers your login-shell `PATH` at startup. (On Windows the process already inherits the user `PATH`, so no recovery is needed.) If "Run Claude" still reports that `claude` is not on `PATH`, make sure `claude` is a real executable on your shell `PATH` — a shell alias or function cannot be spawned.
|
|
||||||
|
|
||||||
**Auto-start at login.** Toggle *Open at Login* from the tray menu or the application menu. On macOS it registers via the first-party `SMAppService` API (Electron's `app.setLoginItemSettings`), so the entry appears under → *System Settings → General → Login Items*. On Windows it writes a per-user `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` entry, visible in *Task Manager → Startup*. When the app is launched at login, it starts **tray-only** — the dashboard window stays hidden until you click the tray icon.
|
|
||||||
|
|
||||||
**Logs.** The Electron main process has no terminal when launched from Finder / the Start menu, so it writes to a per-user log file:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/Library/Logs/Claude Code Monitor/desktop.log # macOS
|
|
||||||
%APPDATA%\Claude Code Monitor\logs\desktop.log # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
Open it from the tray menu → **Show Logs**. Set `CCAM_DESKTOP_VERBOSE=1` to also mirror `info`/`warn` lines to stdout when running via `npm run desktop:dev`.
|
|
||||||
|
|
||||||
**Lifecycle reminder.** Closing the dashboard window only **hides** it — the server and tray keep running. **Quit** (⌘Q or tray → *Quit*) shuts the embedded server down gracefully and exits. Double-launching just focuses the existing window (single-instance lock); it never starts a second server.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
@@ -606,48 +554,3 @@ If the build fails in Stage 1 with `better-sqlite3` errors, this is expected and
|
|||||||
- Ensure you are using the latest Dockerfile (it should use `node:22-alpine` and **not** install `python3`, `make`, or `g++`)
|
- Ensure you are using the latest Dockerfile (it should use `node:22-alpine` and **not** install `python3`, `make`, or `g++`)
|
||||||
- Run `docker build --no-cache -t agent-monitor .` to force a clean rebuild
|
- Run `docker build --no-cache -t agent-monitor .` to force a clean rebuild
|
||||||
- Check that `package.json` has `better-sqlite3` under `optionalDependencies`, not `dependencies`
|
- Check that `package.json` has `better-sqlite3` under `optionalDependencies`, not `dependencies`
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### macOS desktop app — `npm run desktop:dmg` is slow
|
|
||||||
|
|
||||||
This is expected. `desktop:dmg` compiles, packages, and ad-hoc-signs the app **twice** — once for `arm64`, once for `x64` — and emits **both** per-arch DMGs (`…-arm64.dmg` + `…-x64.dmg`). It does not merge them into a single universal binary; the two per-arch DMGs are what ship. Packaging two architectures back-to-back is what takes the time; it is not hung.
|
|
||||||
|
|
||||||
For a build that targets your own Mac, use a single-arch command instead — it builds one architecture and finishes in roughly a minute:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run desktop:dmg:arm64 # Apple Silicon
|
|
||||||
npm run desktop:dmg:x64 # Intel
|
|
||||||
```
|
|
||||||
|
|
||||||
CI already produces both DMGs — pulled either from the [latest GitHub Release](https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor/releases/latest) (CI auto-publishes a `vX.Y.Z` when `package.json` is bumped on `master`) or from the per-commit `ClaudeCodeMonitor-dmg` workflow artifact — so you rarely need to build them locally.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Desktop app — `entry file out/main.js does not exist`
|
|
||||||
|
|
||||||
You ran `electron-builder` without a TypeScript compile. `npm run clean` (in `desktop/`) deletes `out/`, and `electron-builder` only packages — it does not compile. Re-run `npm run desktop:build` first, or use a `desktop:dmg*` / `desktop:win*` script (each one chains `npm run build` for you). Never invoke `electron-builder` bare.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### macOS desktop app — Gatekeeper blocks the app on first launch
|
|
||||||
|
|
||||||
The DMG is **ad-hoc signed** by default (the project ships no paid Apple Developer ID), so macOS shows *"Apple could not verify…"* the first time you open the app. Strip the quarantine attribute:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xattr -cr "/Applications/Claude Code Monitor.app"
|
|
||||||
```
|
|
||||||
|
|
||||||
Or open → *System Settings → Privacy & Security* and click *Open Anyway*. Real Developer ID signing and notarization are opt-in via the `CSC_LINK` / `CSC_KEY_PASSWORD` and `APPLE_ID` / `APPLE_TEAM_ID` / `APPLE_APP_SPECIFIC_PASSWORD` repository secrets — see [`DESKTOP.md`](./DESKTOP.md#notarization-for-the-maintainer).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Windows desktop app — SmartScreen blocks the app on first launch
|
|
||||||
|
|
||||||
The Windows `.exe` (NSIS installer and portable build) is **unsigned** by default, so Windows SmartScreen shows *"Windows protected your PC"* the first time you run it. Click **More info → Run anyway**. Authenticode signing is opt-in via the `CSC_LINK` / `CSC_KEY_PASSWORD` repository secrets — CI picks them up automatically when provided.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Desktop app — no sessions appearing
|
|
||||||
|
|
||||||
The desktop app installs hooks on its **first owned-server boot**, not before. After the app is running, start a **new** Claude Code session and confirm `~/.claude/settings.json` contains entries referencing `hook-handler.js`. If the app adopted an existing server on `4820`, that server's own hook configuration applies instead. For a blank dashboard window, check the desktop log (`~/Library/Logs/Claude Code Monitor/desktop.log` on macOS, `%APPDATA%\Claude Code Monitor\logs\desktop.log` on Windows) via tray → *Show Logs* and use tray → *Restart Server*.
|
|
||||||
|
|||||||
+870
-18
@@ -190,8 +190,9 @@ async function api(method, pathname, body, options = {}) {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
const get = (p) => api("GET", p);
|
const get = (p, b, options) => api("GET", p, undefined, options);
|
||||||
const post = (p, b, options) => api("POST", p, b, options);
|
const post = (p, b, options) => api("POST", p, b, options);
|
||||||
|
const patch = (p, b, options) => api("PATCH", p, b, options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print the standard "server is not running" indicator and exit 1. Every
|
* Print the standard "server is not running" indicator and exit 1. Every
|
||||||
@@ -1520,6 +1521,133 @@ async function cmdLanesAdd(args) {
|
|||||||
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
|
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam skills install` — copy this checkout's `.claude/skills/ship-feature-lane/`
|
||||||
|
* into `~/.claude/skills/ship-feature-lane/`, so `/ship-feature-lane` is
|
||||||
|
* discoverable from a session running inside any lane's own working
|
||||||
|
* directory, not just inside this repo. Pure filesystem action; does not
|
||||||
|
* talk to the dashboard server at all. Reinstall, not merge — overwrites an
|
||||||
|
* existing global copy with the current one.
|
||||||
|
*/
|
||||||
|
function cmdSkillsInstall() {
|
||||||
|
const { installShipFeatureLaneSkill } = require(
|
||||||
|
path.join(REPO_ROOT, "server", "lib", "skills-install.js")
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const result = installShipFeatureLaneSkill({ repoRoot: REPO_ROOT });
|
||||||
|
console.log(`${c.green("✔")} installed ship-feature-lane skill -> ${result.path}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✖ ${err.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||||
|
* (owning session died) and cap oversized hook logs, across every lane on
|
||||||
|
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||||
|
* action, no HTTP round-trip.
|
||||||
|
*/
|
||||||
|
function cmdLanesGc(args) {
|
||||||
|
const dryRun = args.includes("--dry-run");
|
||||||
|
const laneGc = require(path.join(REPO_ROOT, "server", "lib", "lane-gc.js"));
|
||||||
|
|
||||||
|
const reaped = laneGc.reapOrphanMcp({ dryRun });
|
||||||
|
if (reaped.length) {
|
||||||
|
console.log(
|
||||||
|
`${dryRun ? "would reap" : "reaped"} ${reaped.length} orphaned MCP process(es): ${reaped.join(", ")}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("no orphaned MCP processes found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const capped = laneGc.capOversizedLogs({ dryRun });
|
||||||
|
if (capped.length) {
|
||||||
|
for (const { path: logPath, sizeBefore } of capped) {
|
||||||
|
const mb = (sizeBefore / (1024 * 1024)).toFixed(1);
|
||||||
|
console.log(`${dryRun ? "would cap" : "capped"} ${logPath} (${mb}MB -> 2MB)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("no oversized logs found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lanes profile init <repo>` — detect a Node.js project and scaffold
|
||||||
|
* `.ccam/profile/`. Pure filesystem action against the SOURCE repo; does not
|
||||||
|
* talk to the dashboard server at all.
|
||||||
|
*/
|
||||||
|
function cmdLanesProfileInit(args) {
|
||||||
|
const repo = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
const force = args.includes("--force");
|
||||||
|
if (!repo) {
|
||||||
|
console.error("usage: ccam lanes profile init <repo> [--force]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(repo);
|
||||||
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
||||||
|
console.error(`✖ not a directory: ${resolved}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js"));
|
||||||
|
const facts = laneDetect.detectNode(resolved);
|
||||||
|
if (!facts) {
|
||||||
|
console.error(
|
||||||
|
`✖ No detectable Node.js project at ${resolved} (looked for backend/package.json +\n` +
|
||||||
|
" frontend/package.json, or a root package.json).\n" +
|
||||||
|
" Auto-scaffolding currently supports Node.js repos in that layout only.\n" +
|
||||||
|
" Write .ccam/profile/ by hand — see docs/LANES.md."
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = laneDetect.scaffoldProfile(resolved, facts, { force });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "EPROFILEEXISTS") {
|
||||||
|
console.error(`✖ ${err.message} — pass --force to overwrite it.`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${c.green("✔")} Scaffolded .ccam/profile/ at ${resolved} (${facts.layout})`);
|
||||||
|
for (const file of result.written) console.log(` wrote ${file}`);
|
||||||
|
if (result.todos.length) {
|
||||||
|
console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`);
|
||||||
|
for (const todo of result.todos) console.log(` ${todo}`);
|
||||||
|
}
|
||||||
|
console.log(`\nNext: ccam lanes profile check ${repo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lanes profile check [<path>]` — validate a profile without needing a
|
||||||
|
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
||||||
|
* resolution (unlike every other `lanes` subcommand) — this is meant to run
|
||||||
|
* against a bare repo right after `profile init`.
|
||||||
|
*/
|
||||||
|
async function cmdLanesProfileCheck(args) {
|
||||||
|
const target = args.find((arg) => !arg.startsWith("--")) || process.cwd();
|
||||||
|
const resolved = path.resolve(target);
|
||||||
|
const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js"));
|
||||||
|
const result = await laneDetect.checkProfile(resolved);
|
||||||
|
|
||||||
|
if (result.errors.length === 0) {
|
||||||
|
console.log(`${c.green("✔")} profile at ${resolved} looks good`);
|
||||||
|
} else {
|
||||||
|
console.log(`${c.red(`✖ ${result.errors.length} problem(s) at ${resolved}:`)}`);
|
||||||
|
for (const error of result.errors) console.log(` ${error}`);
|
||||||
|
}
|
||||||
|
for (const warning of result.warnings) console.log(`${c.yellow("⚠")} ${warning}`);
|
||||||
|
process.exitCode = result.ok ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Keep these confirmation facts in lockstep with expectedFields() in
|
// Keep these confirmation facts in lockstep with expectedFields() in
|
||||||
// server/routes/lanes.js. The server remains authoritative and rejects an
|
// server/routes/lanes.js. The server remains authoritative and rejects an
|
||||||
// incomplete or stale echo, while the CLI shows exactly what it will send.
|
// incomplete or stale echo, while the CLI shows exactly what it will send.
|
||||||
@@ -1543,13 +1671,19 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
const id = args.find((arg) => !arg.startsWith("--"));
|
const id = args.find((arg) => !arg.startsWith("--"));
|
||||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||||
if (!id) {
|
if (!id) {
|
||||||
console.error(`usage: ccam lanes ${action} <id> [--force] --yes`);
|
const extra = action === "reset" ? " [--keep-db]" : "";
|
||||||
|
console.error(`usage: ccam lanes ${action} <id> [--force]${extra} --yes`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
||||||
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
|
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
|
||||||
|
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||||
|
if (preflight.database) {
|
||||||
|
const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated";
|
||||||
|
console.log(` database: ${preflight.database} (${fate})`);
|
||||||
|
}
|
||||||
if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
|
if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
|
||||||
console.log("Warnings:");
|
console.log("Warnings:");
|
||||||
for (const warning of preflight.warnings) console.log(` ${warning}`);
|
for (const warning of preflight.warnings) console.log(` ${warning}`);
|
||||||
@@ -1579,6 +1713,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
const expect = Object.fromEntries(fields.map((field) => [field, preflight[field]]));
|
const expect = Object.fromEntries(fields.map((field) => [field, preflight[field]]));
|
||||||
const body = { confirm: true, expect };
|
const body = { confirm: true, expect };
|
||||||
if (args.includes("--force")) body.force = true;
|
if (args.includes("--force")) body.force = true;
|
||||||
|
if (keepDb) body.keepDb = true;
|
||||||
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
const error = result.data?.error || {};
|
const error = result.data?.error || {};
|
||||||
@@ -1638,11 +1773,583 @@ async function cmdLanes() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which lane a command is about: an explicit id, or the lane owning the working
|
||||||
|
* directory (longest path-boundary match, the same rule the server uses). A
|
||||||
|
* session running inside a lane never has to know its own id.
|
||||||
|
*
|
||||||
|
* Only the FIRST positional counts as an id, and only when it is all digits.
|
||||||
|
* Scanning the whole argv for a number would swallow flag values —
|
||||||
|
* `ccam lanes logs web --tail 4096` would have addressed lane 4096.
|
||||||
|
*
|
||||||
|
* @param {string[]} args - Arguments after the subcommand.
|
||||||
|
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
||||||
|
*/
|
||||||
|
async function resolveLaneArg(args) {
|
||||||
|
const flagValue = (name) => {
|
||||||
|
const i = args.indexOf(`--${name}`);
|
||||||
|
return i > -1 ? args[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const explicit = flagValue("lane");
|
||||||
|
if (explicit) return { laneId: explicit, rest: args };
|
||||||
|
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||||
|
|
||||||
|
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
||||||
|
const { lanes } = await get("/api/lanes");
|
||||||
|
const match = lanes
|
||||||
|
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
|
||||||
|
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
|
||||||
|
if (!match) {
|
||||||
|
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { laneId: match.id, rest: args };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line per declared port: name, number, listening, and any base drift. */
|
||||||
|
function printRuntime(runtime) {
|
||||||
|
if (!runtime.available) {
|
||||||
|
console.log("no .ccam/profile for this lane — nothing to run.");
|
||||||
|
if (runtime.searched) for (const p of runtime.searched) console.log(` looked in ${p}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!runtime.provisioned) {
|
||||||
|
console.log("profile found, runtime not provisioned yet — run: ccam lanes up");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`);
|
||||||
|
if (runtime.database) {
|
||||||
|
console.log(` database ${runtime.database.name} (test: ${runtime.database.testName})`);
|
||||||
|
}
|
||||||
|
if (runtime.redisIndex != null) {
|
||||||
|
console.log(` redis logical db ${runtime.redisIndex}`);
|
||||||
|
}
|
||||||
|
for (const [name, info] of Object.entries(runtime.ports)) {
|
||||||
|
const drift =
|
||||||
|
info.port && info.port !== info.expected ? ` ⚠ base expects ${info.expected}` : "";
|
||||||
|
console.log(
|
||||||
|
` ${name.padEnd(10)} :${String(info.port ?? "-").padEnd(6)} ` +
|
||||||
|
`${info.listening ? "listening" : "down"}${drift}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const service of runtime.services) {
|
||||||
|
console.log(
|
||||||
|
` ${service.name.padEnd(10)} pid ${service.pid} ${service.alive ? "alive" : "gone"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (runtime.lastError) {
|
||||||
|
console.log(` last error: ${runtime.lastError.code || ""} ${runtime.lastError.message}`);
|
||||||
|
}
|
||||||
|
if (runtime.logs.length) console.log(` logs: ${runtime.logs.join(", ")} (${runtime.logDir})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lanes up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||||
|
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
||||||
|
* lane id.
|
||||||
|
*
|
||||||
|
* Every subcommand resolves the lane from the working directory when no id is
|
||||||
|
* given, so a session inside a lane can call them without knowing its id — this
|
||||||
|
* is the surface the driving skill uses.
|
||||||
|
*/
|
||||||
|
async function cmdLanesRuntime(sub, args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const { laneId, rest: laneArgs } = resolved;
|
||||||
|
|
||||||
|
if (sub === "runtime") {
|
||||||
|
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "up") {
|
||||||
|
const body = {};
|
||||||
|
if (laneArgs.includes("--no-build")) body.build = false;
|
||||||
|
if (laneArgs.includes("--qc")) body.qc = true;
|
||||||
|
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ up lane #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The server answers 202 and boots in the background; poll until the stack
|
||||||
|
// reports healthy or a boot error lands, so the command exits on a real
|
||||||
|
// outcome rather than on "accepted".
|
||||||
|
console.log(`lane #${laneId} booting…`);
|
||||||
|
const deadline = Date.now() + 15 * 60 * 1000;
|
||||||
|
for (;;) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
const runtime = await get(`/api/lanes/${laneId}/runtime`);
|
||||||
|
if (runtime.healthy) {
|
||||||
|
printRuntime(runtime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (runtime.lastError) {
|
||||||
|
console.error(`✖ ${runtime.lastError.code || ""} ${runtime.lastError.message}`);
|
||||||
|
printRuntime(runtime);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
console.error("✖ timed out waiting for the stack to become healthy");
|
||||||
|
printRuntime(runtime);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "down") {
|
||||||
|
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ down lane #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "logs") {
|
||||||
|
const svc = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!svc) {
|
||||||
|
console.error("usage: ccam lanes logs [<id>] <service> [--tail bytes]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const i = laneArgs.indexOf("--tail");
|
||||||
|
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
||||||
|
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||||
|
if (!log.available) {
|
||||||
|
console.log("no logs for this lane yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`);
|
||||||
|
process.stdout.write(log.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "hook") {
|
||||||
|
const name = laneArgs[0];
|
||||||
|
if (!name || name.startsWith("--")) {
|
||||||
|
console.error("usage: ccam lanes hook [<id>] <name> [args…]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await post(
|
||||||
|
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||||
|
{ args: laneArgs.slice(1) },
|
||||||
|
{ allowError: true }
|
||||||
|
);
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ hook ${name} → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`lane #${laneId} running hook ${name} — follow it with: ccam lanes logs ${laneId} ${name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === "sync-base") {
|
||||||
|
const mode = laneArgs.includes("--check")
|
||||||
|
? "check"
|
||||||
|
: laneArgs.includes("--continue")
|
||||||
|
? "continue"
|
||||||
|
: "merge";
|
||||||
|
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||||
|
const result = await post(
|
||||||
|
`/api/lanes/${laneId}/sync-base`,
|
||||||
|
{ mode, branch },
|
||||||
|
{ allowError: true }
|
||||||
|
);
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ sync-base → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code === 5) {
|
||||||
|
console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
|
||||||
|
for (const c of result.collisions) {
|
||||||
|
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
|
||||||
|
}
|
||||||
|
process.exitCode = 5;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code === 4) {
|
||||||
|
console.error(`✖ lane #${laneId} — MERGE CONFLICT (left in place).`);
|
||||||
|
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
|
||||||
|
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
|
||||||
|
console.error(
|
||||||
|
` then: ccam lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
|
||||||
|
);
|
||||||
|
process.exitCode = 4;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "check") {
|
||||||
|
if (result.devDelta === null) {
|
||||||
|
console.log("DEV_DELTA: unknown (no merge-base with origin/development)");
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`DEV_DELTA: ${result.devDelta.length} file(s) changed on origin/development since merge-base`
|
||||||
|
);
|
||||||
|
for (const f of result.devDelta) console.log(` ${f}`);
|
||||||
|
if (result.overlap.length) {
|
||||||
|
console.log(
|
||||||
|
`DEV_OVERLAP: ${result.overlap.length} file(s) — the upstream delta touches the feature's files:`
|
||||||
|
);
|
||||||
|
for (const f of result.overlap) console.log(` ${f}`);
|
||||||
|
} else {
|
||||||
|
console.log("DEV_OVERLAP: none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`lane #${laneId} preflight vs origin/development: OK`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default lock holder identity for the calling lane: `lane<slot>` when
|
||||||
|
* the lane has one allocated, else `lane<id>` — a lane's own row id — as a
|
||||||
|
* fallback for a lane that has never brought its runtime up. `--holder`
|
||||||
|
* always overrides both.
|
||||||
|
*
|
||||||
|
* @param {string[]} argsAfterName - Args AFTER the lock name has already been
|
||||||
|
* consumed by the caller (mirrors `cmdStage`'s `resolveLaneArg(args.slice(1))`
|
||||||
|
* call) — `resolveLaneArg` treats a leading all-digits positional as a lane
|
||||||
|
* id, so the lock name itself must never reach it (a lock literally named
|
||||||
|
* e.g. "3" would otherwise be misread as lane 3).
|
||||||
|
*/
|
||||||
|
async function defaultHolder(argsAfterName) {
|
||||||
|
const explicit = (() => {
|
||||||
|
const i = argsAfterName.indexOf("--holder");
|
||||||
|
return i > -1 ? argsAfterName[i + 1] : undefined;
|
||||||
|
})();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
const resolved = await resolveLaneArg(argsAfterName);
|
||||||
|
if (!resolved) return null;
|
||||||
|
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||||
|
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtLockRow(lock) {
|
||||||
|
const mins = Math.floor(lock.ageSec / 60);
|
||||||
|
return `${lock.name.padEnd(20)} held by ${lock.holder.padEnd(10)} for ${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam lock status [<name>]` — one lock, or every held lock. */
|
||||||
|
async function cmdLockStatus(args) {
|
||||||
|
const name = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (name) {
|
||||||
|
const { locks } = await get("/api/locks");
|
||||||
|
const lock = locks.find((l) => l.name === name);
|
||||||
|
console.log(lock ? fmtLockRow(lock) : `${name}: free`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { locks } = await get("/api/locks");
|
||||||
|
if (!locks.length) {
|
||||||
|
console.log("no locks held");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const lock of locks) console.log(fmtLockRow(lock));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lock acquire <name> [--holder X] [--timeout N]` — polls until the
|
||||||
|
* lock is free (or `--timeout` seconds elapse). Prints a status line every
|
||||||
|
* ~60s of continued waiting so a long wait never reads as a hung command —
|
||||||
|
* this is the CLI-side "heartbeat" the design calls for; it is terminal
|
||||||
|
* output, not a dashboard liveness signal.
|
||||||
|
*/
|
||||||
|
async function cmdLockAcquire(args) {
|
||||||
|
const name = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!name) {
|
||||||
|
console.error("usage: ccam lock acquire <name> [--holder X] [--timeout seconds]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Strip the lock name before handing args to defaultHolder/resolveLaneArg —
|
||||||
|
// see defaultHolder's doc comment for why the name must never reach it.
|
||||||
|
const holder = await defaultHolder(args.filter((a) => a !== name));
|
||||||
|
if (!holder) return; // resolveLaneArg already printed an error
|
||||||
|
|
||||||
|
const timeoutIdx = args.indexOf("--timeout");
|
||||||
|
const timeoutMs =
|
||||||
|
timeoutIdx > -1 && args[timeoutIdx + 1] ? Number(args[timeoutIdx + 1]) * 1000 : null;
|
||||||
|
const deadline = timeoutMs ? Date.now() + timeoutMs : null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let lastPrinted = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const result = await post(
|
||||||
|
`/api/locks/${encodeURIComponent(name)}/acquire`,
|
||||||
|
{ holder },
|
||||||
|
{
|
||||||
|
allowError: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (result.status === undefined || result.data?.acquired) {
|
||||||
|
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - lastPrinted >= 60_000) {
|
||||||
|
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
||||||
|
console.log(
|
||||||
|
`… still waiting for lock "${name}" (held by ${result.data?.holder ?? "unknown"}, waited ${waited}s)`
|
||||||
|
);
|
||||||
|
lastPrinted = Date.now();
|
||||||
|
}
|
||||||
|
if (deadline && Date.now() >= deadline) {
|
||||||
|
console.error(`✖ timed out waiting for lock "${name}"`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam lock release <name> [--holder X]`. */
|
||||||
|
async function cmdLockRelease(args) {
|
||||||
|
const name = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!name) {
|
||||||
|
console.error("usage: ccam lock release <name> [--holder X]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const holder = await defaultHolder(args.filter((a) => a !== name));
|
||||||
|
if (!holder) return;
|
||||||
|
const result = await post(
|
||||||
|
`/api/locks/${encodeURIComponent(name)}/release`,
|
||||||
|
{ holder },
|
||||||
|
{
|
||||||
|
allowError: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ release lock "${name}" → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`${c.green("✔")} released lock "${name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `ccam stage <stage> [flags]` — the lane equivalent of Shipyard's
|
* `ccam stage <stage> [flags]` — the lane equivalent of Shipyard's
|
||||||
* `state.sh N set stage=…`. A skill calls this at each phase boundary so the
|
* `state.sh N set stage=…`. A skill calls this at each phase boundary so the
|
||||||
* dashboard shows a declared stage instead of an inferred one.
|
* dashboard shows a declared stage instead of an inferred one.
|
||||||
*/
|
*/
|
||||||
|
function fmtFeatureRow(f) {
|
||||||
|
const marker = f.archived_at ? " " : "▶ ";
|
||||||
|
return `${marker}${f.slug.padEnd(24)} ${String(f.stage).padEnd(12)} ${f.progress}%${
|
||||||
|
f.archived_at ? ` (archived ${fmtTime(f.archived_at)})` : ""
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam feature list [<id>] [--cwd path]` — every feature this lane has activated. */
|
||||||
|
async function cmdFeatureList(args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
||||||
|
if (!features.length) {
|
||||||
|
console.log("no features activated yet — start one with: ccam feature activate <slug>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const f of features) console.log(fmtFeatureRow(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam feature activate <slug> [--title X] [<id>] [--cwd path]`. */
|
||||||
|
async function cmdFeatureActivate(args) {
|
||||||
|
const slug = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!slug) {
|
||||||
|
console.error("usage: ccam feature activate <slug> [--title text]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const flag = (name) => {
|
||||||
|
const i = args.indexOf(`--${name}`);
|
||||||
|
return i > -1 ? args[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||||
|
if (!resolved) return;
|
||||||
|
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
|
||||||
|
slug,
|
||||||
|
title: flag("title"),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
||||||
|
/** `ccam lanes proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||||
|
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
||||||
|
* by anything else in this codebase; a session or hook calls it explicitly. */
|
||||||
|
async function cmdLanesProofLink(args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
|
||||||
|
console.log(
|
||||||
|
linked
|
||||||
|
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
||||||
|
: "proof/ already linked, nothing to do"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam lanes agents install [<id>] [--cwd path]` — write the ship-feature-lane
|
||||||
|
* agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/,
|
||||||
|
* git-excluded. Never run automatically; a session calls it explicitly. */
|
||||||
|
async function cmdLanesAgentsInstall(args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const result = await post(
|
||||||
|
`/api/lanes/${resolved.laneId}/agents/install`,
|
||||||
|
{},
|
||||||
|
{ allowError: true }
|
||||||
|
);
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ agents install → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`${c.green("✔")} installed: ${result.installed.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||||
|
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||||
|
* profiles. Never run automatically; a session calls it explicitly. */
|
||||||
|
async function cmdLanesMcpSync(args) {
|
||||||
|
const resolved = await resolveLaneArg(args);
|
||||||
|
if (!resolved) return;
|
||||||
|
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`${c.green("✔")} synced: ${result.servers.join(", ")}`);
|
||||||
|
if (result.profilesSeeded.length) {
|
||||||
|
console.log(` seeded profiles: ${result.profilesSeeded.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ccam lanes integration <name> [<id>]` — check whether a named
|
||||||
|
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
||||||
|
* its .ccam/profile/integrations.env. Exit 0 = on, 1 = off. */
|
||||||
|
async function cmdLanesIntegration(args) {
|
||||||
|
const name = args.find((arg) => !arg.startsWith("--"));
|
||||||
|
if (!name) {
|
||||||
|
console.error("usage: ccam lanes integration <name> [<id>]");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
||||||
|
if (!resolved) return;
|
||||||
|
const { enabled } = await get(
|
||||||
|
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||||
|
);
|
||||||
|
console.log(enabled ? "on" : "off");
|
||||||
|
process.exitCode = enabled ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ccam lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||||
|
* template a lane renders against. `ccam lanes add --pipeline` could only set
|
||||||
|
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
||||||
|
* was stuck on `default` with no way back to a 16-node template.
|
||||||
|
*/
|
||||||
|
async function cmdLanesPipeline(args) {
|
||||||
|
const target = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
|
||||||
|
const resolved = await resolveLaneArg(args.filter((a) => a !== target));
|
||||||
|
if (!resolved) return;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||||
|
const { pipelines } = await get("/api/lanes/pipelines");
|
||||||
|
console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||||
|
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
||||||
|
console.log(
|
||||||
|
`${c.green("✔")} lane #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||||
|
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||||
|
);
|
||||||
|
// Switching templates re-resolves the SAME declared stage string against a
|
||||||
|
// different node list, so a stage that meant something in the old pipeline
|
||||||
|
// can land nowhere in the new one. Same warning as `ccam stage`, same reason.
|
||||||
|
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
|
||||||
|
console.error(
|
||||||
|
c.yellow(
|
||||||
|
`! the lane's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
|
||||||
|
`declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
console.error("usage: ccam feature show <slug>");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||||
|
if (!resolved) return;
|
||||||
|
const result = await get(
|
||||||
|
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||||
|
undefined,
|
||||||
|
{ allowError: true }
|
||||||
|
);
|
||||||
|
if (result.status) {
|
||||||
|
console.error(`✖ feature "${slug}" → ${result.data?.error?.message || result.status}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const f = result.feature;
|
||||||
|
console.log(`${f.slug} ${f.archived_at ? "(archived)" : "(active)"}`);
|
||||||
|
console.log(` stage: ${f.stage} status: ${f.status} progress: ${f.progress}%`);
|
||||||
|
for (const node of f.pipeline_nodes) console.log(` ${node.state.padEnd(18)} ${node.label}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function cmdStage(args) {
|
async function cmdStage(args) {
|
||||||
const stage = args[0];
|
const stage = args[0];
|
||||||
if (!stage || stage.startsWith("--")) {
|
if (!stage || stage.startsWith("--")) {
|
||||||
@@ -1657,20 +2364,11 @@ async function cmdStage(args) {
|
|||||||
return i > -1 ? args[i + 1] : undefined;
|
return i > -1 ? args[i + 1] : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
let laneId = flag("lane");
|
// `--lane` wins, otherwise the lane owning this directory. The stage name is
|
||||||
if (!laneId) {
|
// args[0], so only what follows it can carry a lane reference.
|
||||||
const cwd = require("path").resolve(flag("cwd") || process.cwd());
|
const resolved = await resolveLaneArg(args.slice(1));
|
||||||
const { lanes } = await get("/api/lanes");
|
if (!resolved) return;
|
||||||
const match = lanes
|
const laneId = resolved.laneId;
|
||||||
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
|
|
||||||
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
|
|
||||||
if (!match) {
|
|
||||||
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
laneId = match.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
||||||
stage,
|
stage,
|
||||||
@@ -1680,6 +2378,22 @@ async function cmdStage(args) {
|
|||||||
result: flag("result"),
|
result: flag("result"),
|
||||||
});
|
});
|
||||||
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
|
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
|
||||||
|
|
||||||
|
// A stage name matching no node (nor alias) still stores — setStage takes the
|
||||||
|
// string verbatim — but phaseIdx() then returns -1, so nothing renders as
|
||||||
|
// `current` and progress reads 0. Warn, never fail: a typo must not break a
|
||||||
|
// declaration the pipeline can still record, but it must not pass silently.
|
||||||
|
// Skipped for `--result fail`, which paints the node `failed` rather than
|
||||||
|
// `current` and would otherwise look identical to an unknown stage.
|
||||||
|
const nodes = lane.pipeline_nodes || [];
|
||||||
|
if (flag("result") !== "fail" && nodes.length && !nodes.some((n) => n.state === "current")) {
|
||||||
|
console.error(
|
||||||
|
c.yellow(
|
||||||
|
`! "${stage}" matches no node in pipeline "${lane.pipeline}" — recorded, but the ` +
|
||||||
|
`pipeline map won't show it. Nodes: ${nodes.map((n) => n.id).join(", ")}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command catalog ─────────────────────────────────────────────────────────
|
// ── Command catalog ─────────────────────────────────────────────────────────
|
||||||
@@ -1780,12 +2494,91 @@ const COMMAND_GROUPS = [
|
|||||||
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
|
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
|
||||||
"Provision a managed worktree lane",
|
"Provision a managed worktree lane",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"lanes profile init",
|
||||||
|
"<repo> [--force]",
|
||||||
|
"Detect a Node.js project and scaffold .ccam/profile/",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes profile check",
|
||||||
|
"[<path>]",
|
||||||
|
"Validate a profile (path defaults to cwd, not a lane id)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes pipeline",
|
||||||
|
"[<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",
|
"lanes reset|remove|purge",
|
||||||
"<id> [--force] --yes",
|
"<id> [--force] [--keep-db] --yes",
|
||||||
"Show preflight facts, then perform a destructive lane action",
|
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes up|down",
|
||||||
|
"[<id>] [--no-build] [--qc]",
|
||||||
|
"Boot or stop the lane's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the lane owning this directory)",
|
||||||
|
],
|
||||||
|
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
|
||||||
|
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the lane's hook or service logs"],
|
||||||
|
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
|
||||||
|
[
|
||||||
|
"lanes sync-base",
|
||||||
|
"[<id>] [--check|--continue] [branch]",
|
||||||
|
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the lane's current branch)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes proof-link",
|
||||||
|
"[<id>]",
|
||||||
|
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes agents install",
|
||||||
|
"[<id>]",
|
||||||
|
"Write the ship-feature-lane agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/, git-excluded",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes mcp sync",
|
||||||
|
"[<id>]",
|
||||||
|
"Relocate the source repo's MCP servers into <lane>/.mcp.json and seed Chromium profiles",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes integration",
|
||||||
|
"<name> [<id>]",
|
||||||
|
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lanes gc",
|
||||||
|
"[--dry-run]",
|
||||||
|
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"skills install",
|
||||||
|
"",
|
||||||
|
"Install .claude/skills/ship-feature-lane/ into ~/.claude/skills/ so /ship-feature-lane works from any lane",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lock status|acquire|release",
|
||||||
|
"[<name>] [--holder X] [--timeout N]",
|
||||||
|
"Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)",
|
||||||
],
|
],
|
||||||
["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"],
|
["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"],
|
||||||
|
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
|
||||||
|
[
|
||||||
|
"feature activate",
|
||||||
|
"<slug> [--title text] [<id>]",
|
||||||
|
"Switch to a feature by slug, archiving the current one first (echoes the canonicalized slug)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"feature show",
|
||||||
|
"<slug> [<id>]",
|
||||||
|
"Show one feature's saved pipeline (works on an archived one too)",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -2571,12 +3364,71 @@ async function runCommand(argv) {
|
|||||||
if (rest[0] === "add") {
|
if (rest[0] === "add") {
|
||||||
return cmdLanesAdd(rest.slice(1));
|
return cmdLanesAdd(rest.slice(1));
|
||||||
}
|
}
|
||||||
|
if (rest[0] === "profile") {
|
||||||
|
if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2));
|
||||||
|
if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2));
|
||||||
|
console.error(
|
||||||
|
"usage: ccam lanes profile init <repo> [--force] | ccam lanes profile check [<path>]"
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (["reset", "remove", "purge"].includes(rest[0])) {
|
if (["reset", "remove", "purge"].includes(rest[0])) {
|
||||||
return cmdLanesLifecycle(rest[0], rest.slice(1));
|
return cmdLanesLifecycle(rest[0], rest.slice(1));
|
||||||
}
|
}
|
||||||
|
if (["up", "down", "runtime", "logs", "hook", "sync-base"].includes(rest[0])) {
|
||||||
|
return cmdLanesRuntime(rest[0], rest.slice(1));
|
||||||
|
}
|
||||||
|
if (rest[0] === "proof-link") return cmdLanesProofLink(rest.slice(1));
|
||||||
|
if (rest[0] === "agents" && rest[1] === "install") {
|
||||||
|
return cmdLanesAgentsInstall(rest.slice(2));
|
||||||
|
}
|
||||||
|
if (rest[0] === "mcp" && rest[1] === "sync") {
|
||||||
|
return cmdLanesMcpSync(rest.slice(2));
|
||||||
|
}
|
||||||
|
if (rest[0] === "integration") {
|
||||||
|
return cmdLanesIntegration(rest.slice(1));
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
return cmdLanes();
|
return cmdLanes();
|
||||||
|
case "skills": {
|
||||||
|
if (rest[0] === "install") return cmdSkillsInstall();
|
||||||
|
console.error("usage: ccam skills install");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "lock": {
|
||||||
|
const sub = rest[0];
|
||||||
|
if (sub === "status") return cmdLockStatus(rest.slice(1));
|
||||||
|
if (sub === "acquire") return cmdLockAcquire(rest.slice(1));
|
||||||
|
if (sub === "release") return cmdLockRelease(rest.slice(1));
|
||||||
|
console.error(
|
||||||
|
"usage: ccam lock status [<name>] | ccam lock acquire <name> [--holder X] [--timeout N] | ccam lock release <name> [--holder X]"
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "stage":
|
case "stage":
|
||||||
return cmdStage(rest);
|
return cmdStage(rest);
|
||||||
|
case "feature": {
|
||||||
|
const sub = rest[0];
|
||||||
|
if (sub === "list") return cmdFeatureList(rest.slice(1));
|
||||||
|
if (sub === "activate") return cmdFeatureActivate(rest.slice(1));
|
||||||
|
if (sub === "show") return cmdFeatureShow(rest.slice(1));
|
||||||
|
console.error(
|
||||||
|
"usage: ccam feature list | ccam feature activate <slug> [--title text] | ccam feature show <slug>"
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "open":
|
case "open":
|
||||||
return cmdOpen();
|
return cmdOpen();
|
||||||
case "version":
|
case "version":
|
||||||
|
|||||||
@@ -400,6 +400,9 @@ Server broadcasts these event types over WebSocket:
|
|||||||
| `notification.received` | Notification object | Notification hook |
|
| `notification.received` | Notification object | Notification hook |
|
||||||
| `remote_source.status` | `{ id, status, error?, last_sync_at? }` (`status`: `idle`/`syncing`/`ok`/`error`/`deleted`) | Remote Data Source sync poller + `/api/remote-sources` routes |
|
| `remote_source.status` | `{ id, status, error?, last_sync_at? }` (`status`: `idle`/`syncing`/`ok`/`error`/`deleted`) | Remote Data Source sync poller + `/api/remote-sources` routes |
|
||||||
| `remote_data.updated` | `{ sourceId, source, label?, counters?, last_sync_at? }` | Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts `session_created` / `session_updated` (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately |
|
| `remote_data.updated` | `{ sourceId, source, label?, counters?, last_sync_at? }` | Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts `session_created` / `session_updated` (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately |
|
||||||
|
| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a lane's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang |
|
||||||
|
| `lane_runtime` | `{ laneId, runtime? , error? }` | A lane's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
|
||||||
|
| `lane_hook_result` | `{ laneId, hook, code, error? }` | A profile hook exited (`POST /api/lanes/:id/hook/:name`). `code` is `null` when the hook could not be started |
|
||||||
|
|
||||||
### EventBus Pattern
|
### EventBus Pattern
|
||||||
|
|
||||||
|
|||||||
Generated
+17
@@ -10,6 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"d3-sankey": "^0.12.3",
|
"d3-sankey": "^0.12.3",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
@@ -2100,6 +2102,21 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"d3-sankey": "^0.12.3",
|
"d3-sankey": "^0.12.3",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
|
|||||||
@@ -35,11 +35,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./components/Layout`
|
* - `./components/Layout`
|
||||||
* - `./components/SplashScreen`
|
* - `./components/SplashScreen`
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./StatusBadge`
|
* - `./StatusBadge`
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
|
|||||||
@@ -27,11 +27,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
|
|||||||
@@ -31,11 +31,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `CheckboxProps` — exported API; see TSDoc on the symbol for behavior.
|
* - `CheckboxProps` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `Checkbox` — exported API; see TSDoc on the symbol for behavior.
|
* - `Checkbox` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `DateTimePicker` — exported API; see TSDoc on the symbol for behavior.
|
* - `DateTimePicker` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -35,11 +35,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `EmptyStateProps` — exported API; see TSDoc on the symbol for behavior.
|
* - `EmptyStateProps` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `EmptyState` — exported API; see TSDoc on the symbol for behavior.
|
* - `EmptyState` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
* - `../lib/event-grouping`
|
* - `../lib/event-grouping`
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `./DateTimePicker`
|
* - `./DateTimePicker`
|
||||||
|
|||||||
@@ -28,11 +28,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./StatusBadge`
|
* - `./StatusBadge`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `FieldHelpProps` — exported API; see TSDoc on the symbol for behavior.
|
* - `FieldHelpProps` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `FieldHelp` — exported API; see TSDoc on the symbol for behavior.
|
* - `FieldHelp` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
|
|||||||
@@ -35,11 +35,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./Sidebar`
|
* - `./Sidebar`
|
||||||
* - `./UpdateNotifier`
|
* - `./UpdateNotifier`
|
||||||
|
|||||||
@@ -30,11 +30,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `SelectOption` — exported API; see TSDoc on the symbol for behavior.
|
* - `SelectOption` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `SelectProps` — exported API; see TSDoc on the symbol for behavior.
|
* - `SelectProps` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -23,11 +23,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./StatusBadge`
|
* - `./StatusBadge`
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -27,11 +27,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `SplashScreen` — exported API; see TSDoc on the symbol for behavior.
|
* - `SplashScreen` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -28,11 +28,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./Tip`
|
* - `./Tip`
|
||||||
* - `./Skeleton`
|
* - `./Skeleton`
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
* - `./Tip`
|
* - `./Tip`
|
||||||
|
|||||||
@@ -26,11 +26,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./brain`
|
* - `./brain`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `SpeechBubble` — exported API; see TSDoc on the symbol for behavior.
|
* - `SpeechBubble` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./CatAvatar`
|
* - `./CatAvatar`
|
||||||
* - `./SpeechBubble`
|
* - `./SpeechBubble`
|
||||||
|
|||||||
@@ -23,11 +23,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./brain`
|
* - `./brain`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -167,26 +167,11 @@ describe("reduceTabby counts and pulses", () => {
|
|||||||
expect(ok.state.worriedUntil).toBe(0);
|
expect(ok.state.worriedUntil).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("run_status completed exit 0 is happy, nonzero/error/killed is worried", () => {
|
it("run_status updates activity timestamp only (no exit code available)", () => {
|
||||||
const good = reduceTabby(
|
|
||||||
initialTabbyState(T0),
|
|
||||||
runStatusMsg({ status: "completed", exitCode: 0 }),
|
|
||||||
T0
|
|
||||||
);
|
|
||||||
expect(good.pulse).toBe("run_done");
|
|
||||||
expect(deriveMood(good.state, T0)).toBe("happy");
|
|
||||||
const bad = reduceTabby(
|
|
||||||
initialTabbyState(T0),
|
|
||||||
runStatusMsg({ status: "completed", exitCode: 1 }),
|
|
||||||
T0
|
|
||||||
);
|
|
||||||
expect(bad.pulse).toBe("error");
|
|
||||||
const err = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "error" }), T0);
|
|
||||||
expect(err.pulse).toBe("error");
|
|
||||||
const killed = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "killed" }), T0);
|
|
||||||
expect(killed.pulse).toBe("error");
|
|
||||||
const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0);
|
const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0);
|
||||||
expect(running.pulse).toBe(null);
|
expect(running.pulse).toBe(null);
|
||||||
|
const gone = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "gone" }), T0);
|
||||||
|
expect(gone.pulse).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("any handled message refreshes lastActivityAt", () => {
|
it("any handled message refreshes lastActivityAt", () => {
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
@@ -345,25 +340,7 @@ export function reduceTabby(
|
|||||||
case "run_status": {
|
case "run_status": {
|
||||||
const r = msg.data as RunStatusPayload;
|
const r = msg.data as RunStatusPayload;
|
||||||
if (!r) return { state, pulse: null };
|
if (!r) return { state, pulse: null };
|
||||||
// A run that finished cleanly (exit 0, or no exit code reported) → happy.
|
// running / gone → activity only (no exit code to distinguish success/failure).
|
||||||
if (r.status === "completed" && (r.exitCode == null || r.exitCode === 0)) {
|
|
||||||
return {
|
|
||||||
state: { ...state, happyUntil: now + HAPPY_MS, lastActivityAt: now },
|
|
||||||
pulse: "run_done",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Errored, killed, or completed with a nonzero exit code → worried.
|
|
||||||
if (
|
|
||||||
r.status === "error" ||
|
|
||||||
r.status === "killed" ||
|
|
||||||
(r.status === "completed" && r.exitCode != null && r.exitCode !== 0)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
|
||||||
pulse: "error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// spawning / running → activity only.
|
|
||||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./brain`
|
* - `./brain`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -22,11 +22,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `TabbyPos` — exported API; see TSDoc on the symbol for behavior.
|
* - `TabbyPos` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `tabbyPrefs` — exported API; see TSDoc on the symbol for behavior.
|
* - `tabbyPrefs` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -22,11 +22,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./brain`
|
* - `./brain`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -23,11 +23,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/eventBus`
|
* - `../../lib/eventBus`
|
||||||
* - `../../lib/api`
|
* - `../../lib/api`
|
||||||
|
|||||||
@@ -25,11 +25,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./prefs`
|
* - `./prefs`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -32,11 +32,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Tip` — exported API; see TSDoc on the symbol for behavior.
|
* - `Tip` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -33,11 +33,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
|
|||||||
@@ -27,11 +27,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `./Select`
|
* - `./Select`
|
||||||
|
|||||||
@@ -22,11 +22,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/highlight`
|
* - `../../lib/highlight`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -25,11 +25,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/api`
|
* - `../../lib/api`
|
||||||
* - `../../lib/eventBus`
|
* - `../../lib/eventBus`
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./CodeBlock`
|
* - `./CodeBlock`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
* - `./ToolCallBlock`
|
* - `./ToolCallBlock`
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
* - `./CodeBlock`
|
* - `./CodeBlock`
|
||||||
|
|||||||
@@ -22,11 +22,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `ToolStyle` — exported API; see TSDoc on the symbol for behavior.
|
* - `ToolStyle` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `styleForTool` — exported API; see TSDoc on the symbol for behavior.
|
* - `styleForTool` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `TuiSegment` — exported API; see TSDoc on the symbol for behavior.
|
* - `TuiSegment` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `stripAnsi` — exported API; see TSDoc on the symbol for behavior.
|
* - `stripAnsi` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `CopyButton` — exported API; see TSDoc on the symbol for behavior.
|
* - `CopyButton` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `Terminal` — exported API; see TSDoc on the symbol for behavior.
|
* - `Terminal` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -36,11 +36,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./primitives`
|
* - `./primitives`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,24 +1,76 @@
|
|||||||
/**
|
/**
|
||||||
* @file AddLaneModal.tsx
|
* @file AddLaneModal.tsx
|
||||||
* @description The "+ Add lane" flow: pick a SOURCE repo (not a folder to
|
* @description The "+ Add lane" flow, in one of two modes chosen with a
|
||||||
* adopt), pick which of its branches to fork from, name the feature, and the
|
* segmented toggle: "Repo" adopts an existing directory as-is via
|
||||||
* dashboard provisions a managed git worktree via `POST /api/lanes/worktree`
|
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||||||
* — the dashboard invents the lane's own directory and branch name, the same
|
* main repo you want stage detection on); "Worktree" provisions a
|
||||||
* way Shipyard's "+ Add lane" never asks a human to name a folder. The lane
|
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||||||
* returned is `status: "provisioning"`; the existing `lane_update` WebSocket
|
* manually-typed branch name. Either mode's path field can be filled by
|
||||||
* subscription in the Workspace page flips it to idle when the worktree is
|
* typing, by the CwdAutocomplete suggestions, or by browsing
|
||||||
* actually ready, so this component does not poll.
|
* (`FolderBrowseModal`) — a native folder picker cannot hand a web page an
|
||||||
|
* absolute filesystem path, so browsing is server-backed instead. The
|
||||||
|
* worktree lane returned is `status: "provisioning"`: the route answers
|
||||||
|
* before the actual `git worktree add` runs, so this component polls
|
||||||
|
* `GET /api/lanes/:id` until that finishes before running the auto-setup
|
||||||
|
* calls against a cwd that must actually exist on disk first.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FolderOpen } from "lucide-react";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { CwdAutocomplete } from "../run/RunSetup";
|
import { CwdAutocomplete } from "../run/RunSetup";
|
||||||
|
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import type { CwdSuggestion } from "../../lib/api";
|
import type { CwdSuggestion } from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
|
|
||||||
|
/** Polls the lane until the background `git worktree add` finishes (status
|
||||||
|
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||||
|
* lane record, or `null` on timeout. */
|
||||||
|
async function waitForProvisioned(
|
||||||
|
laneId: number,
|
||||||
|
{ intervalMs = 500, timeoutMs = 30000 } = {}
|
||||||
|
): Promise<Lane | null> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
for (;;) {
|
||||||
|
const { lane } = await api.lanes.get(laneId);
|
||||||
|
if (lane.status !== "provisioning") return lane;
|
||||||
|
if (Date.now() >= deadline) return null;
|
||||||
|
await new Promise((r) => window.setTimeout(r, intervalMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
|
||||||
|
function Seg({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
title?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-accent text-white shadow-sm"
|
||||||
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AddLaneModal({
|
export function AddLaneModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -27,36 +79,74 @@ export function AddLaneModal({
|
|||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Called with the newly provisioned (still-provisioning) lane. */
|
/** Called with the newly created (possibly still-provisioning) lane. */
|
||||||
onAdded: (lane: Lane) => void;
|
onAdded: (lane: Lane) => void;
|
||||||
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
||||||
* home, recently-used paths) — reused rather than fetched a second time. */
|
* home, recently-used paths) — reused rather than fetched a second time. */
|
||||||
cwdSuggestions: CwdSuggestion[];
|
cwdSuggestions: CwdSuggestion[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
|
||||||
const [sourceRepo, setSourceRepo] = useState("");
|
const [sourceRepo, setSourceRepo] = useState("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
|
const [branch, setBranch] = useState("");
|
||||||
const [branches, setBranches] = useState<string[] | null>(null);
|
const [branches, setBranches] = useState<string[] | null>(null);
|
||||||
const [base, setBase] = useState("");
|
const [base, setBase] = useState("");
|
||||||
|
const [pipeline, setPipeline] = useState("default");
|
||||||
|
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
|
||||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [browseOpen, setBrowseOpen] = useState(false);
|
||||||
|
const [setupResult, setSetupResult] = useState<{
|
||||||
|
profile: "scaffolded" | "skipped" | "failed";
|
||||||
|
agents: "ok" | "failed";
|
||||||
|
mcp: "ok" | "failed";
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
|
setMode("worktree");
|
||||||
setSourceRepo("");
|
setSourceRepo("");
|
||||||
setTitle("");
|
setTitle("");
|
||||||
|
setBranch("");
|
||||||
setBranches(null);
|
setBranches(null);
|
||||||
setBase("");
|
setBase("");
|
||||||
|
setPipeline("default");
|
||||||
setBranchesError(null);
|
setBranchesError(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setSetupResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The template a lane is created with is the ONLY chance to get it right
|
||||||
|
// from here: nothing else in the UI can change it afterwards, so a lane
|
||||||
|
// silently born on `default` renders an 8-node map for a 16-node workflow.
|
||||||
|
// Fetched on open (templates are file-backed and can change between opens).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
api.lanes
|
||||||
|
.pipelines()
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setPipelines(r.pipelines);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Quiet: the select just falls back to the single `default` option
|
||||||
|
// below, and the lane still gets created.
|
||||||
|
if (!cancelled) setPipelines([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
// Look up the repo's branches once the path settles - debounced so every
|
// Look up the repo's branches once the path settles - debounced so every
|
||||||
// keystroke while typing a path doesn't fire a request against a path that
|
// keystroke while typing a path doesn't fire a request against a path that
|
||||||
// isn't finished yet.
|
// isn't finished yet. Worktree mode only: "Repo" mode adopts as-is and
|
||||||
|
// never forks a branch.
|
||||||
const lookedUpFor = useRef<string>("");
|
const lookedUpFor = useRef<string>("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mode !== "worktree") return;
|
||||||
const path = sourceRepo.trim();
|
const path = sourceRepo.trim();
|
||||||
if (!path) {
|
if (!path) {
|
||||||
setBranches(null);
|
setBranches(null);
|
||||||
@@ -82,23 +172,84 @@ export function AddLaneModal({
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [sourceRepo, t]);
|
}, [mode, sourceRepo, t]);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const repo = sourceRepo.trim();
|
const repo = sourceRepo.trim();
|
||||||
const name = title.trim();
|
const name = title.trim();
|
||||||
if (!repo || !branches || !name) return;
|
if (!repo) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (mode === "repo") {
|
||||||
|
try {
|
||||||
|
const result = await api.lanes.ensure({
|
||||||
|
cwd: repo,
|
||||||
|
title: name || undefined,
|
||||||
|
pipeline,
|
||||||
|
});
|
||||||
|
onAdded(result.lane);
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!branches || !name || !branch.trim()) return;
|
||||||
try {
|
try {
|
||||||
const result = await api.lanes.worktree({
|
const result = await api.lanes.worktree({
|
||||||
sourceRepo: repo,
|
sourceRepo: repo,
|
||||||
title: name,
|
title: name,
|
||||||
base: base || undefined,
|
base: base || undefined,
|
||||||
|
branch: branch.trim(),
|
||||||
|
pipeline,
|
||||||
});
|
});
|
||||||
reset();
|
|
||||||
onAdded(result.lane);
|
onAdded(result.lane);
|
||||||
onClose();
|
|
||||||
|
// POST /worktree returns as soon as the DB row exists (202) — the
|
||||||
|
// actual `git worktree add` runs afterward, in the background, on the
|
||||||
|
// server. Firing setup against the lane's cwd before that finishes
|
||||||
|
// means agents/mcp write into a directory that doesn't exist yet, so
|
||||||
|
// wait for provisioning to leave the "provisioning" status first.
|
||||||
|
const provisionedLane = await waitForProvisioned(result.lane.id);
|
||||||
|
if (!provisionedLane || provisionedLane.status === "failed") {
|
||||||
|
setBusy(false);
|
||||||
|
setError(t("addLaneProvisionFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [profileOutcome, agentsOutcome, mcpOutcome] = await Promise.allSettled([
|
||||||
|
api.lanes.profileInit(result.lane.id),
|
||||||
|
api.lanes.agentsInstall(result.lane.id),
|
||||||
|
api.lanes.mcpSync(result.lane.id),
|
||||||
|
]);
|
||||||
|
setSetupResult({
|
||||||
|
profile:
|
||||||
|
profileOutcome.status === "fulfilled"
|
||||||
|
? profileOutcome.value.scaffolded
|
||||||
|
? "scaffolded"
|
||||||
|
: "skipped"
|
||||||
|
: "failed",
|
||||||
|
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||||
|
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info("[add-lane] auto-setup result:", {
|
||||||
|
profile:
|
||||||
|
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
|
||||||
|
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
|
||||||
|
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the modal open so the setup summary below stays on screen; the
|
||||||
|
// user dismisses it themselves (Cancel/X) once they've seen it, rather
|
||||||
|
// than racing a timer that can close before they've looked at it.
|
||||||
|
setBusy(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -112,9 +263,21 @@ export function AddLaneModal({
|
|||||||
// into after the very first character. useCallback keeps the identity stable
|
// into after the very first character. useCallback keeps the identity stable
|
||||||
// across renders so only mount/unmount (and a real onClose change) refocuses.
|
// across renders so only mount/unmount (and a real onClose change) refocuses.
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
|
// ConfirmModal's own Escape/backdrop/X handling calls this directly.
|
||||||
|
// While the folder browser is open on top of it, that dismissal should
|
||||||
|
// close only the browser, not both modals at once.
|
||||||
|
if (browseOpen) {
|
||||||
|
setBrowseOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
reset();
|
reset();
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose, browseOpen]);
|
||||||
|
|
||||||
|
const disabled =
|
||||||
|
!!setupResult ||
|
||||||
|
!sourceRepo.trim() ||
|
||||||
|
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@@ -124,22 +287,52 @@ export function AddLaneModal({
|
|||||||
cancelLabel={t("destructive.cancel")}
|
cancelLabel={t("destructive.cancel")}
|
||||||
destructive={false}
|
destructive={false}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
disabled={!sourceRepo.trim() || !title.trim() || !branches}
|
disabled={disabled}
|
||||||
onConfirm={submit}
|
onConfirm={submit}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||||
|
<Seg
|
||||||
|
active={mode === "repo"}
|
||||||
|
label={t("mode.repo")}
|
||||||
|
title={t("mode.repoHint")}
|
||||||
|
onClick={() => setMode("repo")}
|
||||||
|
/>
|
||||||
|
<Seg
|
||||||
|
active={mode === "worktree"}
|
||||||
|
label={t("mode.worktree")}
|
||||||
|
title={t("mode.worktreeHint")}
|
||||||
|
onClick={() => setMode("worktree")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||||
{t("addLaneRepoLabel")}
|
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<CwdAutocomplete
|
<CwdAutocomplete
|
||||||
inputId="add-lane-repo"
|
inputId="add-lane-repo"
|
||||||
value={sourceRepo}
|
value={sourceRepo}
|
||||||
onChange={setSourceRepo}
|
onChange={setSourceRepo}
|
||||||
suggestions={cwdSuggestions}
|
suggestions={cwdSuggestions}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneRepoHint")}</p>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBrowseOpen(true)}
|
||||||
|
title={t("browse.title")}
|
||||||
|
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
{t("browse.button")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-fg-muted">
|
||||||
|
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -155,7 +348,28 @@ export function AddLaneModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{branches && (
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
|
||||||
|
{t("addLanePipelineLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="add-lane-pipeline"
|
||||||
|
value={pipeline}
|
||||||
|
onChange={(e) => setPipeline(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
|
||||||
|
(p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "worktree" && branches && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||||
{t("addLaneBaseLabel")}
|
{t("addLaneBaseLabel")}
|
||||||
@@ -178,16 +392,71 @@ export function AddLaneModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{branchesError && !branches && (
|
{mode === "worktree" && branchesError && !branches && (
|
||||||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === "worktree" && branches && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||||||
|
{t("addLaneBranchLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="add-lane-branch"
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
placeholder={t("addLaneBranchPlaceholder")}
|
||||||
|
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setupResult && (
|
||||||
|
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||||||
|
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: t("addLaneSetupProfile"),
|
||||||
|
ok: setupResult.profile !== "failed",
|
||||||
|
skipped: setupResult.profile === "skipped",
|
||||||
|
},
|
||||||
|
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
|
||||||
|
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||||
|
] as const
|
||||||
|
).map((row) => (
|
||||||
|
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"skipped" in row && row.skipped
|
||||||
|
? "text-fg-muted"
|
||||||
|
: row.ok
|
||||||
|
? "text-status-success"
|
||||||
|
: "text-status-danger"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
{row.label}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-xs text-status-danger">
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FolderBrowseModal
|
||||||
|
open={browseOpen}
|
||||||
|
initialPath={sourceRepo.trim() || undefined}
|
||||||
|
onSelect={setSourceRepo}
|
||||||
|
onClose={() => setBrowseOpen(false)}
|
||||||
|
/>
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* @file A server-backed folder browser for the Add Lane modal's path inputs.
|
||||||
|
* Browsers refuse to expose an absolute filesystem path from a native folder
|
||||||
|
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
||||||
|
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
|
||||||
|
* up-one-level button and a click-to-descend list, since this tool is
|
||||||
|
* local-first and the server already trusts arbitrary local paths.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
|
||||||
|
export function FolderBrowseModal({
|
||||||
|
open,
|
||||||
|
initialPath,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||||
|
initialPath?: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async (path?: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setListing(await api.lanes.browse(path));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) void load(initialPath);
|
||||||
|
// Only re-run when the modal actually opens - not on every initialPath
|
||||||
|
// keystroke in the field behind it.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("browse.title")}
|
||||||
|
>
|
||||||
|
<div className="border-b border-border p-3">
|
||||||
|
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||||
|
{listing?.path || "…"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-1">
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{listing?.parent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => load(listing.parent!)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3.5 w-3.5" />
|
||||||
|
..
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{listing?.entries.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => load(entry.path)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{entry.isGitRepo ? (
|
||||||
|
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
|
||||||
|
) : (
|
||||||
|
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{listing && listing.entries.length === 0 && !listing.parent && (
|
||||||
|
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn-ghost border border-border text-xs"
|
||||||
|
>
|
||||||
|
{t("destructive.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!listing}
|
||||||
|
onClick={() => {
|
||||||
|
if (listing) onSelect(listing.path);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("browse.select")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,13 +15,23 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import type { Lane, LaneGitFacts } from "../../lib/types";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
|
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||||
|
|
||||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||||
* each call is three git subprocesses server-side, and a branch name does not
|
* each call is three git subprocesses server-side, and a branch name does not
|
||||||
* change on the timescale the lane list is polled at. */
|
* change on the timescale the lane list is polled at. */
|
||||||
const GIT_REFRESH_MS = 30_000;
|
const GIT_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
|
/** How often a mounted card re-probes its stack. Faster than the git refresh
|
||||||
|
* because a stack dying is exactly what the user needs to see, and slower than
|
||||||
|
* the lane poll because each call opens a socket per declared port. */
|
||||||
|
const RUNTIME_REFRESH_MS = 10_000;
|
||||||
|
|
||||||
|
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
|
||||||
|
* as the git facts (slow, since lock state changes infrequently). */
|
||||||
|
const LOCKS_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lane's own working copy, fetched per card rather than folded into the
|
* The lane's own working copy, fetched per card rather than folded into the
|
||||||
* polled lane list. Absent facts are not an error state: a lane may point at a
|
* polled lane list. Absent facts are not an error state: a lane may point at a
|
||||||
@@ -55,6 +65,105 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
|
|||||||
return facts;
|
return facts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lane's own application stack. Same shape and same silence as the git
|
||||||
|
* facts above: a lane without a `.ccam/profile` simply has no runtime row, which
|
||||||
|
* is the common case and not an error worth a banner.
|
||||||
|
*
|
||||||
|
* `bump` lets an up/down action re-read immediately instead of waiting out the
|
||||||
|
* poll interval.
|
||||||
|
*/
|
||||||
|
function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
|
||||||
|
const [runtime, setRuntime] = useState<LaneRuntime | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const read = () => {
|
||||||
|
api.lanes
|
||||||
|
.runtime(laneId)
|
||||||
|
.then((r) => {
|
||||||
|
if (alive) setRuntime(r);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setRuntime({ available: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
const timer = setInterval(read, RUNTIME_REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [laneId, bump]);
|
||||||
|
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks held by THIS lane, polled the same way runtime/git facts are.
|
||||||
|
*/
|
||||||
|
function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||||
|
const [locks, setLocks] = useState<NamedLock[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!laneSlot) return;
|
||||||
|
let alive = true;
|
||||||
|
const holder = `lane${laneSlot}`;
|
||||||
|
const read = () => {
|
||||||
|
api.locks
|
||||||
|
.list()
|
||||||
|
.then((data) => {
|
||||||
|
if (alive) setLocks(data.locks.filter((l) => l.holder === holder));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* fails silently, same contract as the git/runtime pollers */
|
||||||
|
});
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [laneSlot]);
|
||||||
|
|
||||||
|
return locks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
|
||||||
|
|
||||||
|
function useLaneIntegrations(
|
||||||
|
laneId: number,
|
||||||
|
available: boolean
|
||||||
|
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
|
||||||
|
const [state, setState] = useState<Record<string, boolean> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!available) {
|
||||||
|
setState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
|
||||||
|
.then((results) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const next: Record<string, boolean> = {};
|
||||||
|
INTEGRATION_NAMES.forEach((name, i) => {
|
||||||
|
next[name] = results[i]?.enabled ?? false;
|
||||||
|
});
|
||||||
|
setState(next);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setState(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [laneId, available]);
|
||||||
|
|
||||||
|
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
|
||||||
|
}
|
||||||
|
|
||||||
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||||
active: "bg-status-success",
|
active: "bg-status-success",
|
||||||
idle: "bg-surface-4",
|
idle: "bg-surface-4",
|
||||||
@@ -71,16 +180,124 @@ function since(sec: number | null): string {
|
|||||||
export default function LaneCard({
|
export default function LaneCard({
|
||||||
lane,
|
lane,
|
||||||
onAction,
|
onAction,
|
||||||
|
childWorktrees,
|
||||||
|
onSelectLane,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
onAction: (action: string, body?: Record<string, unknown>) => void;
|
onAction: (action: string, body?: Record<string, unknown>) => void;
|
||||||
|
/** Other lanes whose `source_repo` is this lane's `cwd` — populated only
|
||||||
|
* when this lane is itself a source repo (typically an adopted one) that
|
||||||
|
* other lanes were provisioned as worktrees from. */
|
||||||
|
childWorktrees?: Lane[];
|
||||||
|
/** Jumps the Workspace page's selection to another lane's card. */
|
||||||
|
onSelectLane?: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
|
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||||
|
const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null);
|
||||||
|
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||||
|
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
||||||
const git = useLaneGitFacts(lane.id);
|
const git = useLaneGitFacts(lane.id);
|
||||||
|
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||||
|
const locks = useLaneLocks(lane.slot);
|
||||||
|
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
|
||||||
|
* that prop drives the lane's Claude run, and folding a second lifecycle into
|
||||||
|
* it would make "stop" ambiguous about which thing it stops.
|
||||||
|
*
|
||||||
|
* `up` answers 202 and keeps booting in the background, so the button stays
|
||||||
|
* busy until the server's `lane_runtime` message says the attempt finished —
|
||||||
|
* resolving the request is not the same as the stack being up.
|
||||||
|
*/
|
||||||
|
const runtimeAction = async (which: "up" | "down") => {
|
||||||
|
setRuntimeBusy(which);
|
||||||
|
setBootLine(null);
|
||||||
|
try {
|
||||||
|
if (which === "down") {
|
||||||
|
await api.lanes.down(lane.id);
|
||||||
|
setRuntimeBusy(null);
|
||||||
|
} else {
|
||||||
|
await api.lanes.up(lane.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The failure surfaces as the runtime row's lastError on the next read; a
|
||||||
|
// toast here would say the same thing twice.
|
||||||
|
setRuntimeBusy(null);
|
||||||
|
} finally {
|
||||||
|
setRuntimeBump((n) => n + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runLaneAction = async (
|
||||||
|
which: "agents" | "mcp" | "sync-check",
|
||||||
|
fn: () => Promise<string>
|
||||||
|
) => {
|
||||||
|
setLaneActionBusy(which);
|
||||||
|
setLaneActionResult(null);
|
||||||
|
try {
|
||||||
|
setLaneActionResult(await fn());
|
||||||
|
} catch (err) {
|
||||||
|
setLaneActionResult(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLaneActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAgentsInstall = () =>
|
||||||
|
runLaneAction("agents", async () => {
|
||||||
|
const result = await api.lanes.agentsInstall(lane.id);
|
||||||
|
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMcpSync = () =>
|
||||||
|
runLaneAction("mcp", async () => {
|
||||||
|
const result = await api.lanes.mcpSync(lane.id);
|
||||||
|
return t("actions.mcpSyncResult", { servers: result.servers.join(", ") || "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSyncCheck = () =>
|
||||||
|
runLaneAction("sync-check", async () => {
|
||||||
|
const result = await api.lanes.syncBaseCheck(lane.id);
|
||||||
|
if (result.code === 5 && result.collisions && result.collisions.length > 0) {
|
||||||
|
const c = result.collisions[0]!;
|
||||||
|
return t("actions.syncCheckCollision", { file: c.file, suggestion: c.suggestion });
|
||||||
|
}
|
||||||
|
return t("actions.syncCheckClean", {
|
||||||
|
count: result.devDelta?.length ?? 0,
|
||||||
|
overlap: result.overlap?.length ? result.overlap.join(", ") : "none",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live boot feedback. A build can run for minutes, and a card showing only a
|
||||||
|
* disabled button through all of it reads as a hang. The hook's own output
|
||||||
|
* lines are the honest progress indicator.
|
||||||
|
*/
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
eventBus.subscribe((msg) => {
|
||||||
|
const data = msg.data as { laneId?: number; line?: string } | undefined;
|
||||||
|
if (!data || data.laneId !== lane.id) return;
|
||||||
|
if (msg.type === "lane_hook_output" && typeof data.line === "string") {
|
||||||
|
setBootLine(data.line);
|
||||||
|
}
|
||||||
|
if (msg.type === "lane_runtime") {
|
||||||
|
setRuntimeBusy(null);
|
||||||
|
setBootLine(null);
|
||||||
|
setRuntimeBump((n) => n + 1);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[lane.id]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -144,6 +361,80 @@ export default function LaneCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Runtime strip: the lane's own app stack, shown only for lanes that
|
||||||
|
declare a profile. A port that drifted from `base + slot` is called
|
||||||
|
out — the number is otherwise predictable from the slot, and silently
|
||||||
|
serving on a different one is exactly the surprise worth flagging. */}
|
||||||
|
{runtime?.available && runtime.provisioned && (
|
||||||
|
<div
|
||||||
|
data-testid={`lane-runtime-${lane.id}`}
|
||||||
|
className="mb-3 rounded border border-border bg-surface-2/50 px-2 py-1.5 font-mono text-[11px]"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
|
||||||
|
<span
|
||||||
|
className={runtime.healthy ? "text-status-success" : "text-fg-muted"}
|
||||||
|
data-testid="lane-runtime-state"
|
||||||
|
>
|
||||||
|
{runtime.healthy
|
||||||
|
? t("runtime.healthy")
|
||||||
|
: runtime.up
|
||||||
|
? t("runtime.partial")
|
||||||
|
: t("runtime.down")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{Object.entries(runtime.ports).map(([name, info]) => (
|
||||||
|
<div key={name} className="flex items-center gap-1.5 truncate">
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||||
|
info.listening ? "bg-status-success" : "bg-surface-4"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-fg-secondary">{name}</span>
|
||||||
|
<span className="text-fg-muted">:{info.port ?? "—"}</span>
|
||||||
|
{info.port !== null && info.port !== info.expected && (
|
||||||
|
<span
|
||||||
|
className="truncate text-status-warning/80"
|
||||||
|
title={t("runtime.steppedAsideTitle", { expected: info.expected })}
|
||||||
|
>
|
||||||
|
⚠ {info.expected}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* While a boot is in flight the hook's own latest line IS the
|
||||||
|
progress bar — a build can take minutes, and a disabled button
|
||||||
|
with nothing moving reads as a hang. */}
|
||||||
|
{bootLine !== null && (
|
||||||
|
<div
|
||||||
|
data-testid="lane-runtime-bootline"
|
||||||
|
className="mt-1 truncate text-fg-muted"
|
||||||
|
title={bootLine}
|
||||||
|
>
|
||||||
|
{bootLine}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bootLine === null && runtime.lastError && (
|
||||||
|
<div
|
||||||
|
className="mt-1 truncate text-status-danger/90"
|
||||||
|
title={runtime.lastError.message}
|
||||||
|
>
|
||||||
|
{runtime.lastError.code || "error"}: {runtime.lastError.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locks.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="mb-3 flex items-center gap-1 text-xs text-status-warning"
|
||||||
|
data-testid={`lane-locks-${lane.id}`}
|
||||||
|
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
|
||||||
|
>
|
||||||
|
🔒 {t("locks.held", { count: locks.length })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
|
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
|
||||||
{git?.available && (
|
{git?.available && (
|
||||||
<div data-testid="lane-git" className="space-y-1">
|
<div data-testid="lane-git" className="space-y-1">
|
||||||
@@ -169,6 +460,49 @@ export default function LaneCard({
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
{childWorktrees && childWorktrees.length > 0 && (
|
||||||
|
<div
|
||||||
|
data-testid="lane-child-worktrees"
|
||||||
|
className="mb-3 space-y-1 text-[11px] text-fg-secondary"
|
||||||
|
>
|
||||||
|
<div className="text-fg-muted">
|
||||||
|
{t("worktrees.heading", { count: childWorktrees.length })}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{childWorktrees.map((w) => (
|
||||||
|
<li key={w.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectLane?.(w.id)}
|
||||||
|
className="truncate text-left text-blue-400 hover:underline"
|
||||||
|
title={w.cwd}
|
||||||
|
>
|
||||||
|
#{w.id} {w.title || w.cwd} · {w.status}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{integrations && (
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-[10px]">
|
||||||
|
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
data-testid={`lane-integration-${name}`}
|
||||||
|
className={`rounded-full px-2 py-0.5 ${
|
||||||
|
integrations[name]
|
||||||
|
? "bg-status-success/10 text-status-success"
|
||||||
|
: "bg-surface-2 text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`integrations.${name}`)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* mt-auto pins the controls to the bottom so cards of differing height
|
{/* mt-auto pins the controls to the bottom so cards of differing height
|
||||||
in one grid row still line their buttons up. */}
|
in one grid row still line their buttons up. */}
|
||||||
<div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
|
<div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
|
||||||
@@ -191,6 +525,68 @@ export default function LaneCard({
|
|||||||
{t(`action.${a}`)}
|
{t(`action.${a}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{/* Stack controls, only for a lane whose repo declares a profile.
|
||||||
|
Separate from start/stop above: those drive the lane's Claude run,
|
||||||
|
these drive the application it is working on. */}
|
||||||
|
{runtime?.available && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-runtime-toggle"
|
||||||
|
disabled={runtimeBusy !== null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void runtimeAction(runtime.provisioned && runtime.up ? "down" : "up");
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
title={t("runtime.toggleTitle")}
|
||||||
|
>
|
||||||
|
{runtimeBusy
|
||||||
|
? t(`runtime.busy.${runtimeBusy}`)
|
||||||
|
: runtime.provisioned && runtime.up
|
||||||
|
? t("runtime.stop")
|
||||||
|
: t("runtime.boot")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{runtime?.available && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-agents-install"
|
||||||
|
disabled={laneActionBusy !== null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleAgentsInstall();
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{laneActionBusy === "agents" ? t("actions.busy") : t("actions.agentsInstall")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-mcp-sync"
|
||||||
|
disabled={laneActionBusy !== null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleMcpSync();
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{laneActionBusy === "mcp" ? t("actions.busy") : t("actions.mcpSync")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-sync-check"
|
||||||
|
disabled={laneActionBusy !== null}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleSyncCheck();
|
||||||
|
}}
|
||||||
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{laneActionBusy === "sync-check" ? t("actions.busy") : t("actions.syncCheck")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* Deleting the lane and deleting its history are each their own
|
{/* Deleting the lane and deleting its history are each their own
|
||||||
button, by request: hiding "delete" behind a ⋯ made it unfindable,
|
button, by request: hiding "delete" behind a ⋯ made it unfindable,
|
||||||
and the one label that read as "delete" was `clear`. Neither fires
|
and the one label that read as "delete" was `clear`. Neither fires
|
||||||
@@ -263,6 +659,15 @@ export default function LaneCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{laneActionResult && (
|
||||||
|
<p
|
||||||
|
data-testid="lane-action-result"
|
||||||
|
className="mt-1 truncate text-[11px] text-fg-secondary"
|
||||||
|
>
|
||||||
|
{laneActionResult}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{destructiveAction && (
|
{destructiveAction && (
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file The compact lane tile used in the Workspace carousel. It carries only
|
* @file The compact lane tile used in the Workspace's vertical lane list. It
|
||||||
* what you need to pick a lane — which lane, is it alive, what stage, how far —
|
* carries only what you need to pick a lane — which lane, is it alive, what
|
||||||
* because the full card, its controls and its working-copy facts live in the
|
* stage, how far — because the full card, its controls and its working-copy
|
||||||
* detail panel below. Keeping the tile small is what lets a dozen lanes stay
|
* facts live in the detail panel beside it. Keeping the tile small and full
|
||||||
* scannable in one horizontal row.
|
* width is what lets many lanes stay scannable in one scrolling column.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export default function LaneStripCard({
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
title={lane.cwd}
|
title={lane.cwd}
|
||||||
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
className={`w-full shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||||
selected
|
selected
|
||||||
? "border-accent bg-accent/10"
|
? "border-accent bg-accent/10"
|
||||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @file AddLaneModal.test.tsx
|
* @file AddLaneModal.test.tsx
|
||||||
* @description Pins the "+ Add lane" flow after it was rebuilt around a source
|
* @description Pins the "+ Add lane" flow's two modes: "Worktree" (default -
|
||||||
* repo instead of an existing folder: picking or typing a repo path triggers a
|
* pick a source repo, fork a branch, type a new branch name, submit through
|
||||||
* branch lookup, the base-branch picker only appears once that lookup resolves,
|
* the provisioning endpoint) and "Repo" (adopt a directory as-is through
|
||||||
* confirm submits through the provisioning endpoint (not the adopt/ensure one),
|
* `ensure`, no branch fields). Also covers the branch-lookup debounce, the
|
||||||
* an unresolvable path degrades to a quiet hint instead of blocking the form,
|
* unresolvable-path degrade, server-error handling, the auto-setup summary,
|
||||||
* and a server error surfaces instead of closing the modal.
|
* the provisioning-wait race, and the folder-browse modal.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -18,16 +18,33 @@ import type { CwdSuggestion } from "../../../lib/api";
|
|||||||
import type { Lane } from "../../../lib/types";
|
import type { Lane } from "../../../lib/types";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: { lanes: { branches: vi.fn(), worktree: vi.fn() } },
|
api: {
|
||||||
|
lanes: {
|
||||||
|
branches: vi.fn(),
|
||||||
|
pipelines: vi.fn(),
|
||||||
|
worktree: vi.fn(),
|
||||||
|
ensure: vi.fn(),
|
||||||
|
browse: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
profileInit: vi.fn(),
|
||||||
|
agentsInstall: vi.fn(),
|
||||||
|
mcpSync: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function laneFixture(over: Partial<Lane> = {}): Lane {
|
function laneFixture(over: Partial<Lane> = {}): Lane {
|
||||||
|
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||||
|
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||||
|
// base object below still lists all of them, so the cast asserts what the
|
||||||
|
// literal already proves.
|
||||||
return {
|
return {
|
||||||
id: 9,
|
id: 9,
|
||||||
title: "",
|
title: "",
|
||||||
cwd: "/lanes/repo__feature",
|
cwd: "/lanes/repo__feature",
|
||||||
branch: "feat/feature",
|
branch: "feat/feature",
|
||||||
kind: "managed",
|
kind: "managed",
|
||||||
|
source_repo: null,
|
||||||
pipeline: "default",
|
pipeline: "default",
|
||||||
session_id: null,
|
session_id: null,
|
||||||
run_id: null,
|
run_id: null,
|
||||||
@@ -48,8 +65,10 @@ function laneFixture(over: Partial<Lane> = {}): Lane {
|
|||||||
liveness: "idle",
|
liveness: "idle",
|
||||||
detected_stage: null,
|
detected_stage: null,
|
||||||
detected_signal: null,
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
...over,
|
...over,
|
||||||
};
|
} as Lane;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUGGESTIONS: CwdSuggestion[] = [
|
const SUGGESTIONS: CwdSuggestion[] = [
|
||||||
@@ -59,13 +78,7 @@ const SUGGESTIONS: CwdSuggestion[] = [
|
|||||||
|
|
||||||
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
||||||
return render(
|
return render(
|
||||||
<AddLaneModal
|
<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={SUGGESTIONS} {...over} />
|
||||||
open
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onAdded={vi.fn()}
|
|
||||||
cwdSuggestions={SUGGESTIONS}
|
|
||||||
{...over}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,13 +90,51 @@ async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElem
|
|||||||
await user.click(el);
|
await user.click(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fills the default "Worktree" mode's form up through a resolved branch
|
||||||
|
* list, title, and new-branch name - everything Add lane needs to enable. */
|
||||||
|
async function fillWorktreeForm(
|
||||||
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
|
{ repo = "/Users/tester/projects/repo", title = "demo", branch = "feat/demo" } = {}
|
||||||
|
) {
|
||||||
|
const repoField = screen.getByLabelText("Source repository");
|
||||||
|
await focusField(user, repoField);
|
||||||
|
await user.type(repoField, repo);
|
||||||
|
await screen.findByLabelText("Branch to fork from");
|
||||||
|
await user.type(screen.getByLabelText("Title"), title);
|
||||||
|
await user.type(screen.getByLabelText("New branch name"), branch);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(api.lanes.branches).mockReset();
|
vi.mocked(api.lanes.branches).mockReset();
|
||||||
|
vi.mocked(api.lanes.pipelines)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({
|
||||||
|
pipelines: [
|
||||||
|
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
|
||||||
|
{
|
||||||
|
id: "ship-feature",
|
||||||
|
name: "Ship feature (lane pipeline)",
|
||||||
|
nodes: new Array(16).fill({ id: "n" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
vi.mocked(api.lanes.worktree).mockReset();
|
vi.mocked(api.lanes.worktree).mockReset();
|
||||||
|
vi.mocked(api.lanes.ensure).mockReset();
|
||||||
|
vi.mocked(api.lanes.browse).mockReset();
|
||||||
|
// Provisioning finishes instantly by default - tests that care about the
|
||||||
|
// provisioning-in-progress race override this per-test.
|
||||||
|
vi.mocked(api.lanes.get)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ lane: laneFixture({ status: "idle" }) });
|
||||||
|
vi.mocked(api.lanes.profileInit)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||||
|
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||||
|
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AddLaneModal", () => {
|
describe("AddLaneModal — worktree mode (default)", () => {
|
||||||
it("disables confirm until a repo, a title, and a resolved branch list are all present", () => {
|
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
@@ -100,7 +151,9 @@ describe("AddLaneModal", () => {
|
|||||||
await focusField(user, repoField);
|
await focusField(user, repoField);
|
||||||
await user.type(repoField, "/Users/tester/projects/repo");
|
await user.type(repoField, "/Users/tester/projects/repo");
|
||||||
|
|
||||||
await waitFor(() => expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo"));
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo")
|
||||||
|
);
|
||||||
const base = await screen.findByLabelText("Branch to fork from");
|
const base = await screen.findByLabelText("Branch to fork from");
|
||||||
expect(base).toHaveValue("main"); // the repo's current branch is preselected
|
expect(base).toHaveValue("main"); // the repo's current branch is preselected
|
||||||
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
|
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
|
||||||
@@ -121,7 +174,7 @@ describe("AddLaneModal", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submits through the worktree provisioning endpoint, not ensure", async () => {
|
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
|
||||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||||
const onAdded = vi.fn();
|
const onAdded = vi.fn();
|
||||||
@@ -129,11 +182,7 @@ describe("AddLaneModal", () => {
|
|||||||
renderModal({ onClose, onAdded });
|
renderModal({ onClose, onAdded });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
|
||||||
await focusField(user, repoField);
|
|
||||||
await user.type(repoField, "/Users/tester/projects/repo");
|
|
||||||
await screen.findByLabelText("Branch to fork from");
|
|
||||||
await user.type(screen.getByLabelText("Title"), "New feature");
|
|
||||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -141,10 +190,16 @@ describe("AddLaneModal", () => {
|
|||||||
sourceRepo: "/Users/tester/projects/repo",
|
sourceRepo: "/Users/tester/projects/repo",
|
||||||
title: "New feature",
|
title: "New feature",
|
||||||
base: "main",
|
base: "main",
|
||||||
|
branch: "feat/new-feature",
|
||||||
|
pipeline: "default",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: "provisioning" }));
|
expect(onAdded).toHaveBeenCalledWith(
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect.objectContaining({ id: 9, status: "provisioning" })
|
||||||
|
);
|
||||||
|
// The modal stays open showing the setup summary until dismissed - it
|
||||||
|
// does not close itself just because the lane was added.
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
||||||
@@ -154,11 +209,7 @@ describe("AddLaneModal", () => {
|
|||||||
renderModal({ onClose });
|
renderModal({ onClose });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
await fillWorktreeForm(user, { title: "New feature" });
|
||||||
await focusField(user, repoField);
|
|
||||||
await user.type(repoField, "/Users/tester/projects/repo");
|
|
||||||
await screen.findByLabelText("Branch to fork from");
|
|
||||||
await user.type(screen.getByLabelText("Title"), "New feature");
|
|
||||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
|
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
|
||||||
@@ -169,4 +220,222 @@ describe("AddLaneModal", () => {
|
|||||||
renderModal({ open: false });
|
renderModal({ open: false });
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
|
||||||
|
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||||
|
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||||
|
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
|
||||||
|
});
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
renderModal({ onAdded });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await fillWorktreeForm(user);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
|
||||||
|
expect(api.lanes.agentsInstall).toHaveBeenCalledWith(42);
|
||||||
|
expect(api.lanes.mcpSync).toHaveBeenCalledWith(42);
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still calls onAdded even when every setup call fails", async () => {
|
||||||
|
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||||
|
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||||
|
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
|
||||||
|
});
|
||||||
|
vi.mocked(api.lanes.profileInit).mockRejectedValue(new Error("boom"));
|
||||||
|
vi.mocked(api.lanes.agentsInstall).mockRejectedValue(new Error("boom"));
|
||||||
|
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ onAdded, onClose });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await fillWorktreeForm(user, { title: "demo2" });
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the setup summary and lets the user dismiss it manually", async () => {
|
||||||
|
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||||
|
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||||
|
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
|
||||||
|
});
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ onClose });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await fillWorktreeForm(user, { title: "demo3" });
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Setup")).toBeInTheDocument();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
|
||||||
|
if (!dismissButton) throw new Error("Cancel button not found");
|
||||||
|
await user.click(dismissButton);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
|
||||||
|
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||||
|
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||||
|
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
|
||||||
|
});
|
||||||
|
// First poll still provisioning, second poll reports the background
|
||||||
|
// `git worktree add` failed.
|
||||||
|
vi.mocked(api.lanes.get)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
|
||||||
|
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
renderModal({ onAdded });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await fillWorktreeForm(user, { title: "demo4" });
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||||
|
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
|
||||||
|
expect(api.lanes.profileInit).not.toHaveBeenCalled();
|
||||||
|
expect(api.lanes.agentsInstall).not.toHaveBeenCalled();
|
||||||
|
expect(api.lanes.mcpSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AddLaneModal — repo mode (adopt)", () => {
|
||||||
|
it("hides the branch fields and enables confirm on a path alone", async () => {
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||||
|
const dirField = screen.getByLabelText("Directory");
|
||||||
|
await focusField(user, dirField);
|
||||||
|
await user.type(dirField, "/Users/tester/projects/repo");
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
|
||||||
|
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
|
||||||
|
expect(screen.queryByLabelText("New branch name")).toBeNull();
|
||||||
|
expect(api.lanes.branches).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits through ensure, not worktree, and closes immediately", async () => {
|
||||||
|
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||||
|
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
|
||||||
|
created: true,
|
||||||
|
});
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ onAdded, onClose });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||||
|
const dirField = screen.getByLabelText("Directory");
|
||||||
|
await focusField(user, dirField);
|
||||||
|
await user.type(dirField, "/Users/tester/projects/repo");
|
||||||
|
await user.type(screen.getByLabelText("Title"), "main repo");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.ensure).toHaveBeenCalledWith({
|
||||||
|
cwd: "/Users/tester/projects/repo",
|
||||||
|
title: "main repo",
|
||||||
|
pipeline: "default",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(api.lanes.worktree).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 23 })));
|
||||||
|
// Repo mode never runs the profile/agents/mcp setup summary - it should
|
||||||
|
// close right away like the old adopt flow did.
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AddLaneModal — folder browse", () => {
|
||||||
|
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
|
||||||
|
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||||
|
path: "/Users/tester",
|
||||||
|
parent: "/Users",
|
||||||
|
entries: [{ name: "projects", path: "/Users/tester/projects", isGitRepo: false }],
|
||||||
|
});
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle("Browse for a folder"));
|
||||||
|
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("projects"));
|
||||||
|
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||||
|
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||||
|
path: "/Users/tester",
|
||||||
|
parent: null,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ onClose });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle("Browse for a folder"));
|
||||||
|
await screen.findByRole("dialog", { name: "Browse for a folder" });
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: "Create a lane from a working directory" })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AddLaneModal — pipeline template", () => {
|
||||||
|
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||||
|
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||||
|
// `default` renders 8 nodes for a 16-node workflow with no way back from
|
||||||
|
// any screen.
|
||||||
|
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||||
|
lane: laneFixture({ status: "idle" }),
|
||||||
|
created: true,
|
||||||
|
});
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||||
|
const select = await screen.findByLabelText("Pipeline template");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
const repoField = screen.getByLabelText("Directory");
|
||||||
|
await focusField(user, repoField);
|
||||||
|
await user.type(repoField, "/Users/tester/projects/repo");
|
||||||
|
await user.selectOptions(select, "ship-feature");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.ensure).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ pipeline: "ship-feature" })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
|
||||||
|
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
|
||||||
|
renderModal();
|
||||||
|
|
||||||
|
const select = await screen.findByLabelText("Pipeline template");
|
||||||
|
await waitFor(() => expect(select).toHaveValue("default"));
|
||||||
|
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import { DestructiveLaneModal } from "../DestructiveLaneModal";
|
|||||||
import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types";
|
import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types";
|
||||||
|
|
||||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||||
|
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||||
|
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||||
|
// base object below still lists all of them, so the cast asserts what the
|
||||||
|
// literal already proves.
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "demo",
|
title: "demo",
|
||||||
@@ -47,8 +51,10 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
|||||||
liveness: "idle",
|
liveness: "idle",
|
||||||
detected_stage: null,
|
detected_stage: null,
|
||||||
detected_signal: null,
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
} as Lane;
|
||||||
}
|
}
|
||||||
|
|
||||||
function worktreePreflight(overrides: Partial<LaneWorktreePreflight> = {}): LaneWorktreePreflight {
|
function worktreePreflight(overrides: Partial<LaneWorktreePreflight> = {}): LaneWorktreePreflight {
|
||||||
|
|||||||
@@ -9,30 +9,56 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import LaneCard from "../LaneCard";
|
import LaneCard from "../LaneCard";
|
||||||
import type { Lane } from "../../../lib/types";
|
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
lanes: { git: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }) },
|
lanes: {
|
||||||
|
git: vi.fn(),
|
||||||
|
runtime: vi.fn(),
|
||||||
|
up: vi.fn(),
|
||||||
|
down: vi.fn(),
|
||||||
|
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||||
|
integration: vi.fn(),
|
||||||
|
agentsInstall: vi.fn(),
|
||||||
|
mcpSync: vi.fn(),
|
||||||
|
syncBaseCheck: vi.fn(),
|
||||||
|
},
|
||||||
|
locks: {
|
||||||
|
list: vi.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(api.lanes.git).mockReset();
|
vi.mocked(api.lanes.git).mockReset();
|
||||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||||
|
// A lane with no profile is the default fixture: most lanes never run a
|
||||||
|
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||||||
|
vi.mocked(api.lanes.runtime).mockReset();
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||||
|
vi.mocked(api.lanes.integration).mockReset();
|
||||||
|
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
|
||||||
});
|
});
|
||||||
|
vi.mocked(api.locks.list).mockReset();
|
||||||
|
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||||
|
|
||||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||||
|
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||||
|
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||||
|
// base object below still lists all of them, so the cast asserts what the
|
||||||
|
// literal already proves.
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "demo",
|
title: "demo",
|
||||||
cwd: "/work/demo",
|
cwd: "/work/demo",
|
||||||
branch: "lane/demo",
|
branch: "lane/demo",
|
||||||
kind: "adopted",
|
kind: "adopted",
|
||||||
|
source_repo: null,
|
||||||
pipeline: "default",
|
pipeline: "default",
|
||||||
session_id: null,
|
session_id: null,
|
||||||
run_id: null,
|
run_id: null,
|
||||||
@@ -53,8 +79,10 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
|||||||
liveness: "idle",
|
liveness: "idle",
|
||||||
detected_stage: null,
|
detected_stage: null,
|
||||||
detected_signal: null,
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
} as Lane;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("LaneCard status badge", () => {
|
describe("LaneCard status badge", () => {
|
||||||
@@ -67,6 +95,31 @@ describe("LaneCard status badge", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LaneCard child worktrees", () => {
|
||||||
|
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
|
||||||
|
const onSelectLane = vi.fn();
|
||||||
|
const worktree = makeLane({ id: 8, title: "Worktree A", status: "running" });
|
||||||
|
render(
|
||||||
|
<LaneCard
|
||||||
|
lane={makeLane({ id: 1 })}
|
||||||
|
onAction={vi.fn()}
|
||||||
|
childWorktrees={[worktree]}
|
||||||
|
onSelectLane={onSelectLane}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("lane-child-worktrees")).toBeInTheDocument();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByRole("button", { name: /Worktree A/ }));
|
||||||
|
expect(onSelectLane).toHaveBeenCalledWith(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when there are no child worktrees", () => {
|
||||||
|
render(<LaneCard lane={makeLane({ id: 1 })} onAction={vi.fn()} />);
|
||||||
|
expect(screen.queryByTestId("lane-child-worktrees")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const pipelineNodes: Lane["pipeline_nodes"] = [
|
const pipelineNodes: Lane["pipeline_nodes"] = [
|
||||||
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
||||||
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
||||||
@@ -225,3 +278,206 @@ describe("LaneCard git block", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LaneCard — the lane's own application stack", () => {
|
||||||
|
const upRuntime = {
|
||||||
|
available: true as const,
|
||||||
|
provisioned: true as const,
|
||||||
|
slot: 3,
|
||||||
|
kind: "managed" as const,
|
||||||
|
hooks: ["boot", "health"],
|
||||||
|
profileDir: "/work/demo/.ccam/profile",
|
||||||
|
services: [{ name: "web", pid: 4242, alive: true }],
|
||||||
|
ports: { api: { port: 8003, expected: 8003, listening: true } },
|
||||||
|
steppedAside: false,
|
||||||
|
up: true,
|
||||||
|
healthy: true,
|
||||||
|
logs: ["boot.log"],
|
||||||
|
logDir: "/lanes/.state/lane3/logs",
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("shows nothing at all for a lane whose repo declares no profile", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||||
|
render(<LaneCard lane={makeLane({ title: "no profile" })} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("no profile")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("lane-runtime-1")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("lane-runtime-toggle")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists each declared port with its listening state", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("lane-runtime-1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(":8003")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("lane-runtime-state")).toHaveTextContent(/healthy/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a port that stepped aside from its base, showing the expected number", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||||
|
...upRuntime,
|
||||||
|
steppedAside: true,
|
||||||
|
ports: { api: { port: 8103, expected: 8003, listening: true } },
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(":8103")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/8003/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the stack through /down, never through the run's onAction prop", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
|
||||||
|
vi.mocked(api.lanes.down).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
killed: [4242],
|
||||||
|
runtime: { available: false },
|
||||||
|
});
|
||||||
|
const onAction = vi.fn();
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={onAction} />);
|
||||||
|
|
||||||
|
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.lanes.down).toHaveBeenCalledWith(1));
|
||||||
|
expect(api.lanes.up).not.toHaveBeenCalled();
|
||||||
|
expect(onAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("boots a provisioned-but-down lane and stays busy past the 202", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||||
|
...upRuntime,
|
||||||
|
services: [{ name: "web", pid: 4242, alive: false }],
|
||||||
|
ports: { api: { port: 8003, expected: 8003, listening: false } },
|
||||||
|
up: false,
|
||||||
|
healthy: false,
|
||||||
|
});
|
||||||
|
vi.mocked(api.lanes.up).mockResolvedValue({ ok: true, laneId: 1 });
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.lanes.up).toHaveBeenCalledWith(1));
|
||||||
|
// The request resolving is not the stack being up: the server answered 202
|
||||||
|
// and is still booting, so the button must not go idle yet.
|
||||||
|
await waitFor(() => expect(screen.getByTestId("lane-runtime-toggle")).toBeDisabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the last boot error when nothing is streaming", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||||
|
...upRuntime,
|
||||||
|
up: false,
|
||||||
|
healthy: false,
|
||||||
|
lastError: { at: "2026-08-03T00:00:00Z", code: "EUNHEALTHY", message: "health check failed" },
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/EUNHEALTHY/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/health check failed/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LaneCard — named locks", () => {
|
||||||
|
it("shows a lock badge when this lane holds a named lock", async () => {
|
||||||
|
vi.mocked(api.locks.list).mockResolvedValue({
|
||||||
|
locks: [{ name: "build", holder: "lane1", since: 0, ageSec: 120 }],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("lane-locks-1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/lock/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no lock badge when locks belong to a different lane", async () => {
|
||||||
|
vi.mocked(api.locks.list).mockResolvedValue({
|
||||||
|
locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agents install / mcp sync / integration badges / sync check", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
||||||
|
// keeps the double from having to restate a shape the component never
|
||||||
|
// touches here.
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||||||
|
available: true as const,
|
||||||
|
provisioned: true as const,
|
||||||
|
up: false,
|
||||||
|
slot: 1,
|
||||||
|
profileDir: "/work/demo/.ccam/profile",
|
||||||
|
ports: {},
|
||||||
|
} as unknown as LaneRuntime);
|
||||||
|
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
|
||||||
|
Promise.resolve({ enabled: name === "tracker" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows integration badges reflecting each toggle's state", async () => {
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
const tracker = await screen.findByTestId("lane-integration-tracker");
|
||||||
|
const devQc = await screen.findByTestId("lane-integration-dev_qc");
|
||||||
|
expect(tracker.className).toContain("status-success");
|
||||||
|
expect(devQc.className).not.toContain("status-success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking Install agents calls the API and shows the result", async () => {
|
||||||
|
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
|
||||||
|
installed: ["qc-local.md", "senior-gate-reviewer.md"],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
const button = await screen.findByTestId("lane-agents-install");
|
||||||
|
await userEvent.click(button);
|
||||||
|
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
|
||||||
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking Sync MCP calls the API and shows the result", async () => {
|
||||||
|
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
|
||||||
|
servers: ["playwright"],
|
||||||
|
profilesSeeded: [],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
const button = await screen.findByTestId("lane-mcp-sync");
|
||||||
|
await userEvent.click(button);
|
||||||
|
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
|
||||||
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking Check dev sync reports a clean result", async () => {
|
||||||
|
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
|
||||||
|
code: 0,
|
||||||
|
devDelta: ["a.txt", "b.txt"],
|
||||||
|
overlap: [],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
const button = await screen.findByTestId("lane-sync-check");
|
||||||
|
await userEvent.click(button);
|
||||||
|
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
|
||||||
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking Check dev sync reports a migration collision", async () => {
|
||||||
|
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
|
||||||
|
code: 5,
|
||||||
|
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
|
||||||
|
});
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
const button = await screen.findByTestId("lane-sync-check");
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides all four additions when the lane has no profile", async () => {
|
||||||
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||||
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByTestId("lane-agents-install")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("lane-mcp-sync")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("lane-sync-check")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 /
|
* status / mode chip filters, a free-text search, and the per-row Attach /
|
||||||
* Resume / View actions.
|
* Resume / View actions.
|
||||||
*
|
*
|
||||||
* Props only: no API call of its own. The page passes `activeRuns` and
|
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||||
* `runHistory` in and gets attach / resume / view / refresh back out through
|
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||||
* callbacks; the 2 s refresh ticker the modal runs just calls `onRefresh`.
|
* "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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -33,8 +40,28 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Eye,
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { DashboardRunHistoryItem, RunListResponse, RunMode, RunStatus } from "../../lib/api";
|
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
|
||||||
import { ModeBadge, StatusPill } from "./RunConsole";
|
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 =
|
type RunStatusFilter =
|
||||||
| "all"
|
| "all"
|
||||||
@@ -44,19 +71,21 @@ type RunStatusFilter =
|
|||||||
| "error"
|
| "error"
|
||||||
| "killed"
|
| "killed"
|
||||||
| "abandoned";
|
| "abandoned";
|
||||||
type RunModeFilter = "all" | "conversation" | "headless";
|
|
||||||
|
|
||||||
export interface UnifiedRunRow {
|
export interface UnifiedRunRow {
|
||||||
id: string;
|
id: string;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
mode: RunMode;
|
|
||||||
cwd: string;
|
cwd: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
status: RunStatus;
|
status: RunStatus | "completed" | "error" | "killed" | "abandoned";
|
||||||
promptPreview: string;
|
promptPreview: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
endedAt: number | null;
|
endedAt: number | null;
|
||||||
isLive: boolean;
|
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({
|
export function ActiveRunsSwitcher({
|
||||||
@@ -64,6 +93,7 @@ export function ActiveRunsSwitcher({
|
|||||||
currentHandleId,
|
currentHandleId,
|
||||||
onAttach,
|
onAttach,
|
||||||
runHistory,
|
runHistory,
|
||||||
|
externalSessions = [],
|
||||||
onResumeFromHistory,
|
onResumeFromHistory,
|
||||||
onViewFromHistory,
|
onViewFromHistory,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -72,6 +102,9 @@ export function ActiveRunsSwitcher({
|
|||||||
currentHandleId: string | null;
|
currentHandleId: string | null;
|
||||||
onAttach: (id: string) => void;
|
onAttach: (id: string) => void;
|
||||||
runHistory: DashboardRunHistoryItem[];
|
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;
|
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||||
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
onViewFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@@ -94,37 +127,40 @@ export function ActiveRunsSwitcher({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Merge live in-memory handles + persistent history into one row list.
|
// Merge live in-memory handles + persistent history + externally started
|
||||||
// Live entries dedupe past-history entries with the same id.
|
// sessions into one row list. Live entries dedupe past-history entries with
|
||||||
const rows: UnifiedRunRow[] = useMemo(() => {
|
// 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 out: UnifiedRunRow[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
const seenSessions = new Set<string>();
|
||||||
if (activeRuns) {
|
if (activeRuns) {
|
||||||
for (const r of activeRuns.items) {
|
for (const r of activeRuns.items) {
|
||||||
seen.add(r.id);
|
seen.add(r.id);
|
||||||
|
if (r.sessionId) seenSessions.add(r.sessionId);
|
||||||
out.push({
|
out.push({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
sessionId: r.sessionId,
|
sessionId: r.sessionId,
|
||||||
mode: r.mode,
|
cwd: r.cwd || "",
|
||||||
cwd: r.cwd,
|
|
||||||
model: r.model,
|
model: r.model,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
promptPreview: r.prompt || "",
|
promptPreview: r.promptPreview || "",
|
||||||
startedAt: r.startedAt,
|
startedAt: r.startedAt ? new Date(r.startedAt).getTime() : 0,
|
||||||
endedAt: r.endedAt,
|
endedAt: null,
|
||||||
isLive: r.status === "running" || r.status === "spawning",
|
isLive: r.status === "running",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const h of runHistory) {
|
for (const h of runHistory) {
|
||||||
if (seen.has(h.id)) continue;
|
if (seen.has(h.id)) continue;
|
||||||
seen.add(h.id);
|
seen.add(h.id);
|
||||||
|
if (h.session_id) seenSessions.add(h.session_id);
|
||||||
const startedTs = new Date(h.started_at).getTime() || 0;
|
const startedTs = new Date(h.started_at).getTime() || 0;
|
||||||
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
|
const endedTs = h.ended_at ? new Date(h.ended_at).getTime() : null;
|
||||||
out.push({
|
out.push({
|
||||||
id: h.id,
|
id: h.id,
|
||||||
sessionId: h.session_id,
|
sessionId: h.session_id,
|
||||||
mode: h.mode,
|
|
||||||
cwd: h.cwd,
|
cwd: h.cwd,
|
||||||
model: h.model,
|
model: h.model,
|
||||||
status: h.status,
|
status: h.status,
|
||||||
@@ -134,11 +170,51 @@ export function ActiveRunsSwitcher({
|
|||||||
isLive: h.isLive,
|
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);
|
out.sort((a, b) => b.startedAt - a.startedAt);
|
||||||
return out;
|
return {
|
||||||
}, [activeRuns, runHistory]);
|
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;
|
const totalCount = rows.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -181,7 +257,7 @@ export function ActiveRunsSwitcher({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
onViewFromHistory(item);
|
onViewFromHistory(item);
|
||||||
}}
|
}}
|
||||||
runHistory={runHistory}
|
runHistory={historyItems}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
/>
|
/>
|
||||||
@@ -211,7 +287,6 @@ export function RunsModal({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("all");
|
||||||
const [modeFilter, setModeFilter] = useState<RunModeFilter>("all");
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
// Snappy refresh while the modal is the foreground UI: pull immediately
|
// Snappy refresh while the modal is the foreground UI: pull immediately
|
||||||
@@ -227,25 +302,22 @@ export function RunsModal({
|
|||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const byStatus: Record<string, number> = { all: rows.length };
|
const byStatus: Record<string, number> = { all: rows.length };
|
||||||
const byMode: Record<string, number> = { all: rows.length };
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
byStatus[r.status] = (byStatus[r.status] || 0) + 1;
|
byStatus[r.status] = (byStatus[r.status] || 0) + 1;
|
||||||
byMode[r.mode] = (byMode[r.mode] || 0) + 1;
|
|
||||||
}
|
}
|
||||||
return { byStatus, byMode };
|
return { byStatus };
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
return rows.filter((r) => {
|
return rows.filter((r) => {
|
||||||
if (statusFilter !== "all" && r.status !== statusFilter) return false;
|
if (statusFilter !== "all" && r.status !== statusFilter) return false;
|
||||||
if (modeFilter !== "all" && r.mode !== modeFilter) return false;
|
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
const hay =
|
const hay =
|
||||||
r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || "");
|
r.promptPreview + "\n" + r.cwd + "\n" + (r.sessionId || "") + "\n" + (r.model || "");
|
||||||
return hay.toLowerCase().includes(q);
|
return hay.toLowerCase().includes(q);
|
||||||
});
|
});
|
||||||
}, [rows, statusFilter, modeFilter, search]);
|
}, [rows, statusFilter, search]);
|
||||||
|
|
||||||
const historyById = useMemo(() => {
|
const historyById = useMemo(() => {
|
||||||
const m = new Map<string, DashboardRunHistoryItem>();
|
const m = new Map<string, DashboardRunHistoryItem>();
|
||||||
@@ -261,7 +333,6 @@ export function RunsModal({
|
|||||||
"killed",
|
"killed",
|
||||||
"abandoned",
|
"abandoned",
|
||||||
];
|
];
|
||||||
const MODES: RunModeFilter[] = ["all", "conversation", "headless"];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -343,16 +414,6 @@ export function RunsModal({
|
|||||||
}))}
|
}))}
|
||||||
onChange={(v) => setStatusFilter(v as RunStatusFilter)}
|
onChange={(v) => setStatusFilter(v as RunStatusFilter)}
|
||||||
/>
|
/>
|
||||||
<FilterChipGroup
|
|
||||||
label={t("runs.filterMode", "Mode")}
|
|
||||||
value={modeFilter}
|
|
||||||
options={MODES.map((m) => ({
|
|
||||||
value: m,
|
|
||||||
label: m === "all" ? t("runs.allLabel", "All") : t(`mode.${m}`),
|
|
||||||
count: counts.byMode[m] || 0,
|
|
||||||
}))}
|
|
||||||
onChange={(v) => setModeFilter(v as RunModeFilter)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -467,10 +528,11 @@ function UnifiedRunRowView({
|
|||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
const canResume = row.mode === "conversation" && !!row.sessionId && !row.isLive;
|
// Without mode distinction, offer resume for any finished run with a session.
|
||||||
// Headless runs are single-shot, so resume doesn't apply - but the captured
|
// An external session is live but has no attachable tmux session, so Resume
|
||||||
// transcript is still worth viewing. Link to the Session detail page.
|
// (a new tmux-backed `claude --resume` in its cwd) is what it gets instead.
|
||||||
const canView = row.mode === "headless" && !!row.sessionId && !row.isLive;
|
const canResume = !!row.sessionId && (!row.isLive || !!row.external);
|
||||||
|
const canView = !!row.sessionId && !row.isLive;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-5 py-3 transition-colors ${
|
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">
|
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
<StatusPill status={row.status} />
|
<StatusPill status={row.status} />
|
||||||
<ModeBadge mode={row.mode} />
|
|
||||||
{row.isLive && (
|
{row.isLive && (
|
||||||
<span className="text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
|
<span className="text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 px-1.5 py-0.5 rounded-full inline-flex items-center gap-1">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
|
||||||
{t("runs.liveBadge", "live")}
|
{t("runs.liveBadge", "live")}
|
||||||
</span>
|
</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 && (
|
{isCurrent && (
|
||||||
<span className="text-[10px] font-semibold text-accent bg-accent/10 border border-accent/25 px-1.5 py-0.5 rounded-full">
|
<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")}
|
{t("runs.currentBadge", "current")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto inline-flex items-center gap-1.5">
|
<span className="ml-auto inline-flex items-center gap-1.5">
|
||||||
{row.isLive && !isCurrent && (
|
{row.isLive && !row.external && !isCurrent && (
|
||||||
<button
|
<button
|
||||||
onClick={onAttach}
|
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"
|
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 && (
|
{canResume && (
|
||||||
<button
|
<button
|
||||||
onClick={onResume}
|
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"
|
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" />
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
|||||||
@@ -5,14 +5,15 @@
|
|||||||
* the Run page and the Workspace page can both mount the same panel.
|
* the Run page and the Workspace page can both mount the same panel.
|
||||||
*
|
*
|
||||||
* What lives here:
|
* What lives here:
|
||||||
* - `RunSetup` — mode (conversation / headless), fresh-vs-resume source, the
|
* - `RunSetup` — fresh-vs-resume source, the prompt editor, and the cwd /
|
||||||
* prompt editor, and the cwd / model / permission-mode / effort fields,
|
* model / permission-mode / effort fields, plus the concurrency hint and
|
||||||
* plus the concurrency hint and the Start button. Its disabled state is
|
* the Start button. Its disabled state is driven by the `binaryFound` prop,
|
||||||
* driven by the `binaryFound` prop, so a missing `claude` binary is a
|
* so a missing `claude` binary is a surfaced state here rather than a probe
|
||||||
* surfaced state here rather than a probe of its own.
|
* of its own. Picking a session to resume starts that run immediately —
|
||||||
* above the panel, with its own localStorage-persisted minimized state.
|
* a resume carries its own history, so there is nothing to type first;
|
||||||
|
* the prompt stays optional for resumes and required for fresh runs.
|
||||||
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
|
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
|
||||||
* `ModelPicker`, and the small `ModeOption` / `Field` layout helpers.
|
* `ModelPicker`, and the small `Field` layout helper.
|
||||||
*
|
*
|
||||||
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
|
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
|
||||||
* lifecycle — the page owns `api.run.start` and hands the result back through
|
* lifecycle — the page owns `api.run.start` and hands the result back through
|
||||||
@@ -45,18 +46,53 @@ import type {
|
|||||||
RunListResponse,
|
RunListResponse,
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
RunMode,
|
RunStartArgs,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { Session } from "../../lib/types";
|
import type { Session } from "../../lib/types";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
import { PromptEditor } from "./RunConsole";
|
|
||||||
import type { SlashCommand } from "./RunConsole";
|
// Minimal SlashCommand type (from deleted RunConsole)
|
||||||
|
export interface SlashCommand {
|
||||||
|
name: string;
|
||||||
|
source: "project" | "user" | "plugin" | "builtin";
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal PromptEditor component (from deleted RunConsole)
|
||||||
|
interface PromptEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (s: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
placeholder: string;
|
||||||
|
rows?: number;
|
||||||
|
slashCommands: SlashCommand[];
|
||||||
|
fileCwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Limitations banner (above the config card) ────────────────────────
|
// ── Limitations banner (above the config card) ────────────────────────
|
||||||
|
|
||||||
interface RunSetupProps {
|
interface RunSetupProps {
|
||||||
mode: RunMode;
|
laneId: number;
|
||||||
onModeChange: (m: RunMode) => void;
|
|
||||||
prompt: string;
|
prompt: string;
|
||||||
onPromptChange: (s: string) => void;
|
onPromptChange: (s: string) => void;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -70,7 +106,7 @@ interface RunSetupProps {
|
|||||||
onEffortChange: (e: EffortLevel) => void;
|
onEffortChange: (e: EffortLevel) => void;
|
||||||
binaryFound: boolean;
|
binaryFound: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onStart: () => void;
|
onStart: (args: RunStartArgs) => void;
|
||||||
activeRuns: RunListResponse | null;
|
activeRuns: RunListResponse | null;
|
||||||
resumeSession: Session | null;
|
resumeSession: Session | null;
|
||||||
onResumeSessionChange: (s: Session | null) => void;
|
onResumeSessionChange: (s: Session | null) => void;
|
||||||
@@ -78,54 +114,27 @@ interface RunSetupProps {
|
|||||||
* sessions. Undefined when no lane is selected (the picker then lists
|
* sessions. Undefined when no lane is selected (the picker then lists
|
||||||
* everything, same as before lanes existed). */
|
* everything, same as before lanes existed). */
|
||||||
laneCwd?: string;
|
laneCwd?: string;
|
||||||
slashCommands: SlashCommand[];
|
slashCommands?: SlashCommand[];
|
||||||
runHistory: DashboardRunHistoryItem[];
|
runHistory: DashboardRunHistoryItem[];
|
||||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RunSetup(props: RunSetupProps) {
|
export function RunSetup(props: RunSetupProps) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const atCap =
|
const atCap = false; // TODO: re-add when concurrency info is available
|
||||||
props.activeRuns != null && props.activeRuns.activeCount >= props.activeRuns.maxConcurrent;
|
|
||||||
const isResume = !!props.resumeSession;
|
const isResume = !!props.resumeSession;
|
||||||
const [resumePicked, setResumePicked] = useState(isResume);
|
const [resumePicked, setResumePicked] = useState(isResume);
|
||||||
// Keep "resume picked" in sync with the parent. Two cases:
|
// Keep "resume picked" in sync with the parent. Parent set a resume session
|
||||||
// 1. Parent set a resume session (e.g. user clicked Resume in the runs
|
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
|
||||||
// modal) - flip the radio so the picker is shown and the selection
|
// is shown and the selection is visible.
|
||||||
// is visible.
|
|
||||||
// 2. Parent cleared the session and mode flipped to headless - clear
|
|
||||||
// the radio so the form is honest.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isResume && !resumePicked) setResumePicked(true);
|
if (isResume && !resumePicked) setResumePicked(true);
|
||||||
else if (!isResume && resumePicked && props.mode === "headless") setResumePicked(false);
|
}, [isResume, resumePicked]);
|
||||||
}, [isResume, resumePicked, props.mode]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-surface-1">
|
<div className="rounded-xl border border-border bg-surface-1">
|
||||||
{/* Mode and source on one line. Both are two-way choices made once at
|
{/* Fresh vs resume source picker */}
|
||||||
spawn time, so a segmented row carries them; the longer explanations
|
|
||||||
live in each button's title rather than in a paragraph. */}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
||||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
|
||||||
<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")}`}
|
|
||||||
onClick={() => {
|
|
||||||
props.onModeChange("headless");
|
|
||||||
setResumePicked(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{props.mode === "conversation" && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||||
<Seg
|
<Seg
|
||||||
active={!resumePicked}
|
active={!resumePicked}
|
||||||
@@ -147,13 +156,19 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<SessionPicker
|
<SessionPicker
|
||||||
selected={props.resumeSession}
|
selected={props.resumeSession}
|
||||||
onSelect={props.onResumeSessionChange}
|
onSelect={(s) => {
|
||||||
cwd={props.laneCwd}
|
props.onResumeSessionChange(s);
|
||||||
|
// Pass the picked session explicitly: the parent's state
|
||||||
|
// update has not landed yet on this tick, so reading
|
||||||
|
// props.resumeSession here would resume nothing.
|
||||||
|
if (s && !props.busy) handleStart(props, s);
|
||||||
|
}}
|
||||||
|
// Fall back to the typed cwd when no lane is selected — otherwise
|
||||||
|
// an unfiltered picker lists every session from every repo.
|
||||||
|
cwd={props.laneCwd || props.cwd.trim() || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prompt */}
|
{/* Prompt */}
|
||||||
@@ -164,10 +179,10 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
<PromptEditor
|
<PromptEditor
|
||||||
value={props.prompt}
|
value={props.prompt}
|
||||||
onChange={props.onPromptChange}
|
onChange={props.onPromptChange}
|
||||||
onSubmit={props.onStart}
|
onSubmit={() => handleStart(props)}
|
||||||
placeholder={t("fields.promptPlaceholder")}
|
placeholder={t("fields.promptPlaceholderTerminal")}
|
||||||
rows={5}
|
rows={5}
|
||||||
slashCommands={props.slashCommands}
|
slashCommands={props.slashCommands ?? []}
|
||||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 text-[10px] text-fg-muted">
|
<div className="mt-1 text-[10px] text-fg-muted">
|
||||||
@@ -234,20 +249,16 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
{atCap ? (
|
{atCap ? (
|
||||||
<span className="inline-flex items-center gap-1.5 text-status-warning">
|
<span className="inline-flex items-center gap-1.5 text-status-warning">
|
||||||
<AlertCircle className="w-3.5 h-3.5" />
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
{t("concurrency.atCap", { max: props.activeRuns?.maxConcurrent ?? 0 })}
|
{t("concurrency.atCap", { max: 0 })}
|
||||||
</span>
|
|
||||||
) : props.activeRuns && props.activeRuns.activeCount > 0 ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-fg-secondary">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse" />
|
|
||||||
{t("concurrency.active", { count: props.activeRuns.activeCount })}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={props.onStart}
|
onClick={() => handleStart(props)}
|
||||||
disabled={
|
disabled={
|
||||||
!props.binaryFound ||
|
!props.binaryFound ||
|
||||||
!props.prompt.trim() ||
|
// A resume needs no prompt — the session it continues is the input.
|
||||||
|
(!props.resumeSession && !props.prompt.trim()) ||
|
||||||
props.busy ||
|
props.busy ||
|
||||||
atCap ||
|
atCap ||
|
||||||
(resumePicked && !props.resumeSession) ||
|
(resumePicked && !props.resumeSession) ||
|
||||||
@@ -270,6 +281,23 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `session` overrides `props.resumeSession` for the auto-start fired straight
|
||||||
|
* out of the picker, before the parent's state has caught up. */
|
||||||
|
function handleStart(props: RunSetupProps, session?: Session) {
|
||||||
|
const resume = session ?? props.resumeSession;
|
||||||
|
props.onStart({
|
||||||
|
laneId: props.laneId,
|
||||||
|
// A resume is pinned to its own session's folder — that's what the locked
|
||||||
|
// cwd field shows, so it's what gets sent.
|
||||||
|
cwd: resume?.cwd || props.cwd || undefined,
|
||||||
|
model: props.model || undefined,
|
||||||
|
permissionMode: props.permissionMode || undefined,
|
||||||
|
effort: props.effort || undefined,
|
||||||
|
resumeSessionId: resume?.id || undefined,
|
||||||
|
initialPrompt: props.prompt || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** One segment of a two-way inline choice. The explanation rides on `title`
|
/** One segment of a two-way inline choice. The explanation rides on `title`
|
||||||
* instead of a hint line, which is what keeps the row to one line. */
|
* instead of a hint line, which is what keeps the row to one line. */
|
||||||
function Seg({
|
function Seg({
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* @file TerminalView.tsx
|
||||||
|
* @description Renders one lane's live terminal — a real `xterm.js` instance
|
||||||
|
* attached via WebSocket to the server's `/ws-pty/:runId` path (see
|
||||||
|
* server/lib/pty-attach.js), which is itself a `node-pty`-backed
|
||||||
|
* `tmux attach-session`. Binary WS frames are raw PTY bytes in both
|
||||||
|
* directions; a JSON text frame carries the initial `resize` on mount and
|
||||||
|
* the server's one-shot `exit` notice when the pane process ends.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
|
interface TerminalViewProps {
|
||||||
|
runId: string;
|
||||||
|
wsBaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const term = new Terminal({ convertEol: true, fontSize: 13, cursorBlink: true });
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
if (containerRef.current) term.open(containerRef.current);
|
||||||
|
fit.fit();
|
||||||
|
|
||||||
|
const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
|
||||||
|
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||||
|
// would hand onmessage a Blob that the string checks below never match,
|
||||||
|
// silently dropping all terminal output. "arraybuffer" keeps it sync.
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||||
|
const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer) : event.data;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
// A JSON control frame is the only thing that starts with `{"type"`.
|
||||||
|
if (data.startsWith('{"type"')) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data);
|
||||||
|
if (msg.type === "exit") {
|
||||||
|
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* not JSON — fall through and render as PTY output */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
term.write(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataDisposable = term.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
fit.fit();
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
dataDisposable.dispose();
|
||||||
|
ws.close();
|
||||||
|
term.dispose();
|
||||||
|
};
|
||||||
|
}, [runId, wsBaseUrl]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 i18n from "i18next";
|
||||||
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
||||||
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
||||||
|
import type { Session } from "../../../lib/types";
|
||||||
|
|
||||||
const LIVE_ID = "run-live";
|
const LIVE_ID = "run-live";
|
||||||
const PAST_ID = "run-past";
|
const PAST_ID = "run-past";
|
||||||
@@ -29,12 +30,11 @@ const activeRuns = {
|
|||||||
{
|
{
|
||||||
id: LIVE_ID,
|
id: LIVE_ID,
|
||||||
sessionId: "sess-live",
|
sessionId: "sess-live",
|
||||||
mode: "conversation",
|
|
||||||
cwd: "/tmp/live",
|
cwd: "/tmp/live",
|
||||||
model: "claude-opus-5",
|
model: "claude-opus-5",
|
||||||
status: "running",
|
status: "running",
|
||||||
prompt: "the live prompt",
|
startedAt: "2000-01-01T00:50:00Z",
|
||||||
startedAt: 3000,
|
promptPreview: "the live prompt",
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -44,7 +44,6 @@ function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistor
|
|||||||
return {
|
return {
|
||||||
id: PAST_ID,
|
id: PAST_ID,
|
||||||
session_id: "sess-past",
|
session_id: "sess-past",
|
||||||
mode: "conversation",
|
|
||||||
cwd: "/tmp/past",
|
cwd: "/tmp/past",
|
||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -64,12 +63,26 @@ const PAST = historyItem({});
|
|||||||
const HEADLESS = historyItem({
|
const HEADLESS = historyItem({
|
||||||
id: HEADLESS_ID,
|
id: HEADLESS_ID,
|
||||||
session_id: "sess-headless",
|
session_id: "sess-headless",
|
||||||
mode: "headless",
|
|
||||||
cwd: "/tmp/headless",
|
cwd: "/tmp/headless",
|
||||||
prompt_preview: "the headless prompt",
|
prompt_preview: "the headless prompt",
|
||||||
started_at: new Date(1000).toISOString(),
|
started_at: new Date(1000).toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>> = {}) {
|
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
||||||
const spies = {
|
const spies = {
|
||||||
onAttach: vi.fn(),
|
onAttach: vi.fn(),
|
||||||
@@ -95,10 +108,9 @@ function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sessionId: `sess-${id}`,
|
sessionId: `sess-${id}`,
|
||||||
mode: "conversation",
|
|
||||||
cwd: `/tmp/${id}`,
|
cwd: `/tmp/${id}`,
|
||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
status: "completed",
|
status: "abandoned",
|
||||||
promptPreview: `prompt of ${id}`,
|
promptPreview: `prompt of ${id}`,
|
||||||
startedAt: 1000,
|
startedAt: 1000,
|
||||||
endedAt: 2000,
|
endedAt: 2000,
|
||||||
@@ -192,6 +204,44 @@ describe("ActiveRunsSwitcher", () => {
|
|||||||
expect(screen.queryByText("stale copy")).toBeNull();
|
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", () => {
|
it("fires attach with the run id of the row that was clicked", () => {
|
||||||
const { spies } = renderSwitcher();
|
const { spies } = renderSwitcher();
|
||||||
openModal();
|
openModal();
|
||||||
@@ -216,39 +266,31 @@ describe("RunsModal", () => {
|
|||||||
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fires resume with the history item behind a finished conversation row", () => {
|
it("fires resume with the history item behind a finished row", () => {
|
||||||
const { spies } = renderModal([row(PAST_ID)]);
|
const { spies } = renderModal([row(PAST_ID)]);
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
expect(spies.onResume).toHaveBeenCalledWith(PAST);
|
expect(spies.onResume).toHaveBeenCalledWith(PAST);
|
||||||
expect(spies.onView).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fires view — not resume — for a finished headless row", () => {
|
it("fires view for a finished row", () => {
|
||||||
const { spies } = renderModal([row(HEADLESS_ID, { mode: "headless" })]);
|
const { spies } = renderModal([row(HEADLESS_ID)]);
|
||||||
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
|
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
|
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
|
||||||
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
|
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
|
||||||
expect(spies.onResume).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters by status, by mode and by free text", () => {
|
it("filters by status and by free text", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
|
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
|
||||||
row("b", { status: "error", promptPreview: "bravo" }),
|
row("b", { status: "killed", promptPreview: "bravo" }),
|
||||||
row("c", { status: "completed", mode: "headless", promptPreview: "charlie" }),
|
row("c", { status: "abandoned", promptPreview: "charlie" }),
|
||||||
];
|
];
|
||||||
renderModal(rows);
|
renderModal(rows);
|
||||||
|
|
||||||
fireEvent.click(chip(i18n.t("run:status.error")));
|
fireEvent.click(chip(i18n.t("run:status.killed")));
|
||||||
expect(screen.getByText("bravo")).toBeTruthy();
|
expect(screen.getByText("bravo")).toBeTruthy();
|
||||||
expect(screen.queryByText("alpha")).toBeNull();
|
expect(screen.queryByText("alpha")).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(allChip(0));
|
fireEvent.click(allChip(0));
|
||||||
fireEvent.click(chip(i18n.t("run:mode.headless")));
|
|
||||||
expect(screen.getByText("charlie")).toBeTruthy();
|
|
||||||
expect(screen.queryByText("bravo")).toBeNull();
|
|
||||||
|
|
||||||
fireEvent.click(allChip(1));
|
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText(
|
screen.getByPlaceholderText(
|
||||||
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
|
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ type Spies = ReturnType<typeof renderSetup>["spies"];
|
|||||||
|
|
||||||
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
|
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
|
||||||
const spies = {
|
const spies = {
|
||||||
onModeChange: vi.fn(),
|
|
||||||
onPromptChange: vi.fn(),
|
onPromptChange: vi.fn(),
|
||||||
onCwdChange: vi.fn(),
|
onCwdChange: vi.fn(),
|
||||||
onModelChange: vi.fn(),
|
onModelChange: vi.fn(),
|
||||||
@@ -52,7 +51,7 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
|
|||||||
const utils = render(
|
const utils = render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<RunSetup
|
<RunSetup
|
||||||
mode="conversation"
|
laneId={1}
|
||||||
prompt="do the thing"
|
prompt="do the thing"
|
||||||
cwd="/Users/tester"
|
cwd="/Users/tester"
|
||||||
cwdSuggestions={SUGGESTIONS}
|
cwdSuggestions={SUGGESTIONS}
|
||||||
@@ -95,15 +94,6 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("RunSetup — selections report through callbacks", () => {
|
describe("RunSetup — selections report through callbacks", () => {
|
||||||
it("reports the mode from the one-shot / conversation options", () => {
|
|
||||||
const { spies } = renderSetup();
|
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:mode.headless")));
|
|
||||||
expect(spies.onModeChange).toHaveBeenCalledWith("headless");
|
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:mode.conversation")));
|
|
||||||
expect(spies.onModeChange).toHaveBeenLastCalledWith("conversation");
|
|
||||||
onlyCalled(spies, "onModeChange");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports the prompt from the editor", () => {
|
it("reports the prompt from the editor", () => {
|
||||||
const { spies } = renderSetup({ prompt: "" });
|
const { spies } = renderSetup({ prompt: "" });
|
||||||
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
|
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
|
||||||
@@ -171,7 +161,7 @@ describe("RunSetup — missing binary and other blocked states", () => {
|
|||||||
expect(runButton().disabled).toBe(false);
|
expect(runButton().disabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still disables Run without a prompt, without a cwd, or at the concurrency cap", () => {
|
it("still disables Run without a prompt or without a cwd", () => {
|
||||||
const { unmount } = renderSetup({ prompt: " " });
|
const { unmount } = renderSetup({ prompt: " " });
|
||||||
expect(runButton().disabled).toBe(true);
|
expect(runButton().disabled).toBe(true);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -179,12 +169,6 @@ describe("RunSetup — missing binary and other blocked states", () => {
|
|||||||
const noCwd = renderSetup({ cwd: "" });
|
const noCwd = renderSetup({ cwd: "" });
|
||||||
expect(runButton().disabled).toBe(true);
|
expect(runButton().disabled).toBe(true);
|
||||||
noCwd.unmount();
|
noCwd.unmount();
|
||||||
|
|
||||||
renderSetup({
|
|
||||||
activeRuns: { items: [], activeCount: 2, maxConcurrent: 2 } as never,
|
|
||||||
});
|
|
||||||
expect(runButton().disabled).toBe(true);
|
|
||||||
expect(screen.getByText(i18n.t("run:concurrency.atCap", { max: 2 }))).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the Starting… label while busy", () => {
|
it("shows the Starting… label while busy", () => {
|
||||||
@@ -217,9 +201,47 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists everything when no lane is selected", async () => {
|
it("starts the resume immediately when a session is picked", async () => {
|
||||||
const { api } = await import("../../../lib/api");
|
const { api } = await import("../../../lib/api");
|
||||||
renderSetup({ laneCwd: undefined });
|
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||||
|
sessions: [
|
||||||
|
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "" });
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||||
|
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
|
||||||
|
|
||||||
|
expect(spies.onResumeSessionChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: "sess-in-lane" })
|
||||||
|
);
|
||||||
|
// No prompt typed, no Run click - the pick itself is the start, and it
|
||||||
|
// carries the session's own cwd rather than the form's.
|
||||||
|
expect(spies.onStart).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ resumeSessionId: "sess-in-lane", cwd: "/Users/tester/lane-a" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the typed cwd when no lane is selected", async () => {
|
||||||
|
const { api } = await import("../../../lib/api");
|
||||||
|
renderSetup({ laneCwd: undefined, cwd: "/Users/tester" });
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
|
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(api.sessions.list).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ cwd: "/Users/tester" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists everything when no lane is selected and cwd is empty", async () => {
|
||||||
|
const { api } = await import("../../../lib/api");
|
||||||
|
renderSetup({ laneCwd: undefined, cwd: "" });
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||||
|
|
||||||
@@ -237,7 +259,7 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
|||||||
rerender(
|
rerender(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<RunSetup
|
<RunSetup
|
||||||
mode="conversation"
|
laneId={1}
|
||||||
prompt="do the thing"
|
prompt="do the thing"
|
||||||
cwd="/Users/tester"
|
cwd="/Users/tester"
|
||||||
cwdSuggestions={SUGGESTIONS}
|
cwdSuggestions={SUGGESTIONS}
|
||||||
@@ -251,7 +273,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
|||||||
slashCommands={[]}
|
slashCommands={[]}
|
||||||
runHistory={[]}
|
runHistory={[]}
|
||||||
laneCwd="/Users/tester/lane-b"
|
laneCwd="/Users/tester/lane-b"
|
||||||
onModeChange={vi.fn()}
|
|
||||||
onPromptChange={vi.fn()}
|
onPromptChange={vi.fn()}
|
||||||
onCwdChange={vi.fn()}
|
onCwdChange={vi.fn()}
|
||||||
onModelChange={vi.fn()}
|
onModelChange={vi.fn()}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @file TerminalView.test.tsx
|
||||||
|
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||||
|
* a WS connection to the right URL, writes incoming binary frames to the
|
||||||
|
* mocked terminal, and forwards typed input as outgoing binary frames.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import { TerminalView } from "../TerminalView";
|
||||||
|
|
||||||
|
const writeMock = vi.fn();
|
||||||
|
const onDataHandlers: Array<(d: string) => void> = [];
|
||||||
|
const openMock = vi.fn();
|
||||||
|
const disposeMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@xterm/xterm", () => ({
|
||||||
|
Terminal: vi.fn().mockImplementation(() => ({
|
||||||
|
open: openMock,
|
||||||
|
write: writeMock,
|
||||||
|
onData: (fn: (d: string) => void) => {
|
||||||
|
onDataHandlers.push(fn);
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
dispose: disposeMock,
|
||||||
|
loadAddon: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
|
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
class MockWebSocket {
|
||||||
|
static instances: MockWebSocket[] = [];
|
||||||
|
url: string;
|
||||||
|
sent: unknown[] = [];
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((e: { data: unknown }) => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
MockWebSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
send(data: unknown) {
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// @ts-expect-error test override
|
||||||
|
global.WebSocket = MockWebSocket;
|
||||||
|
|
||||||
|
describe("TerminalView", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
MockWebSocket.instances = [];
|
||||||
|
onDataHandlers.length = 0;
|
||||||
|
writeMock.mockClear();
|
||||||
|
openMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it("opens a WS connection to the run's ws-pty path", () => {
|
||||||
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/ccam-lane-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes incoming WS data to the terminal", () => {
|
||||||
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws.onopen?.();
|
||||||
|
ws.onmessage?.({ data: "hello" });
|
||||||
|
expect(writeMock).toHaveBeenCalledWith("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes binary ArrayBuffer frames (server sends PTY output as binary)", () => {
|
||||||
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws.onopen?.();
|
||||||
|
const bytes = new TextEncoder().encode("hello-binary").buffer;
|
||||||
|
ws.onmessage?.({ data: bytes });
|
||||||
|
expect(writeMock).toHaveBeenCalledWith("hello-binary");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards terminal keystrokes as outgoing WS sends", () => {
|
||||||
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
onDataHandlers[0]!("ls -la\r");
|
||||||
|
expect(ws.sent).toEqual(["ls -la\r"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,11 +23,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `AgentCollaborationNetworkProps` — exported API; see TSDoc on the symbol for behavior.
|
* - `AgentCollaborationNetworkProps` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `AgentCollaborationNetwork` — exported API; see TSDoc on the symbol for behavior.
|
* - `AgentCollaborationNetwork` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
|||||||
@@ -25,11 +25,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
* - `../../lib/format`
|
* - `../../lib/format`
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
* - `../../lib/format`
|
* - `../../lib/format`
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/api`
|
* - `../../lib/api`
|
||||||
* - `../../lib/format`
|
* - `../../lib/format`
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -29,11 +29,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/api`
|
* - `../../lib/api`
|
||||||
* - `../../lib/eventBus`
|
* - `../../lib/eventBus`
|
||||||
|
|||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../../lib/types`
|
* - `../../lib/types`
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../i18n`
|
* - `../i18n`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -20,11 +20,6 @@
|
|||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Observability
|
|
||||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
||||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
||||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/types`
|
* - `../lib/types`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user