feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
# Agent Conversation Viewer Design
|
||||
|
||||
## Overview
|
||||
|
||||
Add a conversation viewer to the SessionDetail page, enabling visual inspection of Main Agent and sub-agent interactions (message content and tool call details), with data sourced from real-time JSONL transcript files.
|
||||
|
||||
## Problem
|
||||
|
||||
The current dashboard tracks agent sessions, events, and tool usage at a summary level, but does not expose the actual conversation content — user messages, assistant replies, tool call parameters, and tool results. Users cannot see what each agent actually did or said, limiting debugging and audit capabilities.
|
||||
|
||||
### v2 Additional Problems: Poor Pagination UX + No Real-time Updates
|
||||
|
||||
After v1 implementation, two core UX issues emerged:
|
||||
|
||||
1. **Pagination doesn't match conversation intuition** — v1 uses offset-based pagination starting from the beginning, so users see the oldest messages first and must page through to reach recent interactions, which doesn't align with chat product conventions.
|
||||
2. **No real-time updates** — v1 doesn't subscribe to WebSocket events, so users must manually refresh to see new messages, making it impossible to follow active sessions in real time.
|
||||
3. **Sub-agent selection uses database IDs** — v1's `agent_id` parameter relies on database agent IDs, but JSONL files are named with short IDs (e.g. `ad18a79192af10ed1`), causing a mismatch that prevents sub-agent transcripts from loading.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Data source | Real-time JSONL reads | Data is always current, no extra storage needed |
|
||||
| UI location | Conversation tab within SessionDetail | User-requested; keeps agent tree in the same context |
|
||||
| Claude home path | Configurable via `CLAUDE_HOME` env var | Supports non-default paths like `~/.codefuse/engine/cc/` |
|
||||
| Message rendering | Collapsible tool calls and thinking blocks | Keeps the view scannable; expand for details |
|
||||
| Load strategy (v2) | Chat-flow: load latest N by default, scroll up for history | Matches chat product intuition; users care most about recent interactions |
|
||||
| Real-time updates (v2) | WebSocket `new_event` triggers incremental load | Active sessions don't need manual refresh |
|
||||
| Agent selection (v2) | Filesystem scan + dropdown | Bypasses database ID mismatch by using file short IDs directly |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
**v1 (deprecated):**
|
||||
```
|
||||
User clicks "Conversation" tab
|
||||
→ Frontend calls GET /api/sessions/:id/transcript[?agent_id=xxx&limit=50&offset=0]
|
||||
→ Server resolves JSONL path via claude-home.js
|
||||
→ Server reads and parses JSONL file
|
||||
→ Server returns structured message list
|
||||
→ Frontend renders MessageList (with collapsible blocks)
|
||||
```
|
||||
|
||||
**v2 Chat-flow (current implementation):**
|
||||
```
|
||||
Initial load:
|
||||
User opens Conversation tab
|
||||
→ GET /api/sessions/:id/transcripts ← fetch available transcript list
|
||||
→ GET /api/sessions/:id/transcript?limit=50 ← default returns latest 50 messages
|
||||
→ Frontend renders message list + auto-scrolls to bottom
|
||||
|
||||
Real-time updates:
|
||||
CLI Hook → POST /api/hooks/event → processEvent()
|
||||
→ broadcast("new_event", {session_id, ...})
|
||||
→ WebSocket → ConversationView
|
||||
→ GET /api/sessions/:id/transcript?after=N ← incremental load
|
||||
→ Append to bottom + auto-scroll (if user is at bottom)
|
||||
|
||||
History load:
|
||||
User scrolls to top
|
||||
→ GET /api/sessions/:id/transcript?before=M&limit=50 ← load older messages
|
||||
→ Prepend to top + preserve scroll position (no jump)
|
||||
```
|
||||
|
||||
### Configurable Claude Home Directory
|
||||
|
||||
New module `server/lib/claude-home.js` centralizes all Claude directory path logic:
|
||||
|
||||
```
|
||||
CLAUDE_HOME env var (default: ~/.claude)
|
||||
├── projects/<encoded-cwd>/<session-id>.jsonl ← main session transcript
|
||||
│ (encoding rule: all non-alphanumeric chars → "-", e.g. "/Users/txj/.codefuse" → "-Users-txj--codefuse")
|
||||
├── projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl ← sub-agent transcript
|
||||
│ (sub-agent ID format: ad18a79192af10ed1, acompact-f8427be966459435)
|
||||
└── settings.json ← hooks configuration
|
||||
```
|
||||
|
||||
Existing hardcoded paths in `import-history.js`, `install-hooks.js`, and `settings.js` are migrated to use this module.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### GET /api/sessions/:id/transcripts (v2 new)
|
||||
|
||||
List available transcript files for a session (main + sub-agents), scanned directly from the filesystem.
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"transcripts": [
|
||||
{ "id": "main", "name": "Main Agent", "type": "main", "has_transcript": true },
|
||||
{ "id": "ad18a79192af10ed1", "name": "code-reviewer", "type": "subagent", "subagent_type": "code-reviewer", "has_transcript": true },
|
||||
{ "id": "acompact-f8427be966459435", "name": "Context Compaction", "type": "compaction", "has_transcript": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Design notes:**
|
||||
|
||||
- Bypasses database agent IDs; scans the filesystem directly for JSONL file short IDs
|
||||
- `id` field maps directly to the filename: `agent-<id>.jsonl`, used as the `agent_id` parameter for the `transcript` API
|
||||
- Compaction file name format: `agent-acompact-<hex>.jsonl`, id is `acompact-<hex>`
|
||||
- Attempts to read `.meta.json` in the same directory for agent type description
|
||||
- Falls back to scanning all `projects/` subdirectories when the exact encoded path doesn't exist
|
||||
|
||||
### GET /api/sessions/:id/transcript
|
||||
|
||||
Read a session's JSONL transcript file and return a structured message list.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `agent_id` | string | null | Transcript short ID (from `transcripts` endpoint); omit for main session |
|
||||
| `limit` | number | 50 | Max messages to return (max 200) |
|
||||
| `after` | number | null | Incremental mode: only return messages with JSONL line > after (v2 new) |
|
||||
| `before` | number | null | History mode: only return the latest N messages with JSONL line < before (v2 new) |
|
||||
| `offset` | number | 0 | Legacy pagination offset (compatible, mutually exclusive with after/before) |
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"type": "user",
|
||||
"timestamp": "2026-04-24T10:23:45Z",
|
||||
"content": [
|
||||
{ "type": "text", "text": "Please implement the login feature" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"timestamp": "2026-04-24T10:23:52Z",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"usage": { "input_tokens": 1500, "output_tokens": 800 },
|
||||
"content": [
|
||||
{ "type": "text", "text": "I'll help you implement the login feature." },
|
||||
{ "type": "thinking", "text": "Let me analyze the codebase..." },
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "Read",
|
||||
"id": "toolu_abc123",
|
||||
"input": { "file_path": "/src/auth.ts" }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total": 120,
|
||||
"has_more": true,
|
||||
"last_line": 523,
|
||||
"first_line": 474
|
||||
}
|
||||
```
|
||||
|
||||
**v2 New Response Fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `last_line` | number | JSONL line number of the last message in the current response; used as the `after` parameter for incremental requests |
|
||||
| `first_line` | number | JSONL line number of the first message in the current response; used as the `before` parameter for history loading |
|
||||
|
||||
**Loading Modes:**
|
||||
|
||||
| Mode | Parameters | Behavior | Use Case |
|
||||
|------|-----------|----------|----------|
|
||||
| Default | No after/before/offset | Return the latest N messages | Initial load |
|
||||
| Incremental | `after=N` | Return messages with line > N (up to limit) | WebSocket-triggered new message loading |
|
||||
| History | `before=M` | Return the latest N messages with line < M | Scroll-up to load older messages |
|
||||
| Compatible | `offset=K` | Skip first K, return next N | Legacy pagination (kept for compatibility) |
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Condition |
|
||||
|--------|-----------|
|
||||
| 200 | When JSONL file doesn't exist, returns empty `{ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 }` |
|
||||
| 404 | Session ID not found in database |
|
||||
|
||||
**Implementation Rules:**
|
||||
|
||||
- Only extract entries with `type: "user"` or `type: "assistant"`; skip system/progress entries
|
||||
- Match `tool_use` and `tool_result` via `id` field; unpaired tool_use shows no result section
|
||||
- Truncate individual content exceeding 10KB, appending `[truncated]`
|
||||
- Re-read the file on every request (no server-side caching) to ensure real-time freshness
|
||||
- When `cwd` is null, scan all `projects/` subdirectories to find the JSONL for the sessionId
|
||||
- Internally use JSONL line numbers as cursors; remove the `line` field from responses, expose `first_line` / `last_line` to the client
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### SessionDetail Page Changes
|
||||
|
||||
Replace the current flat layout with a **tabbed interface**:
|
||||
|
||||
```
|
||||
[Agents] [Conversation] [Timeline]
|
||||
```
|
||||
|
||||
- **Agents tab** — existing agent hierarchy tree (active by default)
|
||||
- **Conversation tab** — new conversation viewer
|
||||
- **Timeline tab** — existing event timeline
|
||||
|
||||
### Conversation Tab Components
|
||||
|
||||
**v2 Chat-flow architecture:**
|
||||
|
||||
```
|
||||
ConversationView.tsx
|
||||
├── TranscriptSelector — dropdown selector (v2 replaces AgentFilter)
|
||||
├── ScrollContainer — scrollable message container
|
||||
│ ├── HistoryLoader — scroll-up history loading indicator
|
||||
│ └── MessageList.tsx
|
||||
│ ├── UserMessage — user message
|
||||
│ └── AssistantMessage
|
||||
│ ├── TextBlock — plain text content
|
||||
│ ├── ThinkingBlock — collapsible thinking content
|
||||
│ └── ToolCallBlock — collapsible tool call + result
|
||||
│ ├── ToolUse — tool name + parameters
|
||||
│ └── ToolResult — execution result / error
|
||||
└── NewMsgButton — "New messages" floating button (v2 new)
|
||||
```
|
||||
|
||||
### TranscriptSelector (v2 replaces AgentFilter)
|
||||
|
||||
- Top dropdown selector: `[Main Agent ▾]` or `[Context Compaction ▾]`
|
||||
- Data source: `GET /api/sessions/:id/transcripts` (filesystem scan, not database)
|
||||
- Reloads the corresponding transcript on switch
|
||||
- Only shown when transcripts > 1
|
||||
- Message count displayed alongside: `518 messages`
|
||||
|
||||
### Chat-flow Behavior (v2 new)
|
||||
|
||||
**Initial load:**
|
||||
- Call `transcript?limit=50` to get the latest 50 messages
|
||||
- Auto-scroll to bottom after rendering
|
||||
- Track `last_line` and `first_line` for subsequent requests
|
||||
|
||||
**Real-time updates (WebSocket-driven):**
|
||||
- Subscribe to `eventBus` `new_event` events
|
||||
- Only process events where `session_id` matches the current session
|
||||
- On event, call `transcript?after=last_line&limit=50` for incremental loading
|
||||
- If user is at bottom (< 100px from bottom), auto-scroll to latest message
|
||||
- If user has scrolled up, show "New messages" floating button; click to scroll to bottom
|
||||
|
||||
**Scroll-up history loading:**
|
||||
- Listen for scroll events; trigger when `scrollTop < 50` and `has_more` is true
|
||||
- Call `transcript?before=first_line&limit=50` to fetch older messages
|
||||
- Prepend to top of list; preserve scroll position via `scrollHeight` delta
|
||||
- Show spinner while loading; show "↑ Scroll up for older messages" hint at top
|
||||
|
||||
**Key Refs:**
|
||||
- `lastLineRef` — tracks the JSONL line number of the newest message, used for incremental requests
|
||||
- `firstLineRef` — tracks the JSONL line number of the oldest loaded message, used for history loading
|
||||
- `scrollContainerRef` — scroll container DOM reference
|
||||
- `isAtBottomRef` — boolean flag tracking whether user is at the bottom
|
||||
|
||||
### Message Rendering
|
||||
|
||||
- **User messages**: right-aligned, blue background, display text content
|
||||
- **Assistant messages**: left-aligned, default background, including:
|
||||
- Model name and token usage as faded metadata
|
||||
- Text blocks rendered inline
|
||||
- Thinking blocks: collapsed by default, click to expand (dimmed style)
|
||||
- Tool calls: collapsed by default showing only tool name, click to expand:
|
||||
- Tool name as header with icon
|
||||
- Input parameters formatted as JSON (collapsible)
|
||||
- Tool result with success/error indicator
|
||||
|
||||
### Interaction Details
|
||||
|
||||
- **Long text truncation**: content over 500 characters is truncated by default, with an "expand" link
|
||||
- **Lazy loading (v2)**: initial load of latest 50 messages; scroll-up auto-loads older 50; WebSocket-driven incremental append
|
||||
- **Real-time updates (v2)**: on WebSocket `new_event` with matching `session_id`, incrementally load new messages
|
||||
- **Auto-scroll (v2)**: auto-scroll to latest when user is at bottom; show floating "New messages" button when user has scrolled up
|
||||
- **Empty state**: when JSONL is missing or empty, show "No conversation records found."
|
||||
|
||||
---
|
||||
|
||||
## Server Module: claude-home.js
|
||||
|
||||
```js
|
||||
// Centralized Claude home directory path management
|
||||
function getClaudeHome() {
|
||||
return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude");
|
||||
}
|
||||
|
||||
function getProjectsDir() {
|
||||
return path.join(getClaudeHome(), "projects");
|
||||
}
|
||||
|
||||
function getSettingsPath() {
|
||||
return path.join(getClaudeHome(), "settings.json");
|
||||
}
|
||||
|
||||
// Encoding rule: all non-alphanumeric characters replaced with "-"
|
||||
// Example: "/Users/txj/.codefuse" → "-Users-txj--codefuse"
|
||||
function encodeCwd(cwd) {
|
||||
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
}
|
||||
|
||||
function getTranscriptPath(sessionId, cwd) {
|
||||
if (!cwd) return null;
|
||||
const encoded = encodeCwd(cwd);
|
||||
const candidate = path.join(getProjectsDir(), encoded, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
// Fallback: scan projects/ subdirectories
|
||||
return findTranscriptPath(sessionId);
|
||||
}
|
||||
|
||||
function getSubagentTranscriptPath(sessionId, cwd, agentId) {
|
||||
if (!cwd) return null;
|
||||
const encoded = encodeCwd(cwd);
|
||||
const candidate = path.join(getProjectsDir(), encoded, sessionId, "subagents", `agent-${agentId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
// Fallback: scan all project directories
|
||||
return findSubagentTranscriptPath(sessionId, agentId);
|
||||
}
|
||||
|
||||
function findTranscriptPath(sessionId) {
|
||||
// Fallback: when cwd is unknown, scan projects/ subdirectories
|
||||
const projectsDir = getProjectsDir();
|
||||
if (!fs.existsSync(projectsDir)) return null;
|
||||
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
||||
for (const d of dirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// v2 new: support prefix fuzzy matching for compaction type
|
||||
function findSubagentTranscriptPath(sessionId, agentId) {
|
||||
const projectsDir = getProjectsDir();
|
||||
if (!fs.existsSync(projectsDir)) return null;
|
||||
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
||||
for (const d of dirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const subagentsDir = path.join(projectsDir, d.name, sessionId, "subagents");
|
||||
if (!fs.existsSync(subagentsDir)) continue;
|
||||
// Exact match
|
||||
const exact = path.join(subagentsDir, `agent-${agentId}.jsonl`);
|
||||
if (fs.existsSync(exact)) return exact;
|
||||
// Prefix fuzzy match (compaction type: agentId starts with "acompact-")
|
||||
if (agentId.startsWith("acompact-")) {
|
||||
const files = fs.readdirSync(subagentsDir);
|
||||
const match = files.find(f => f.startsWith("agent-acompact-") && f.endsWith(".jsonl"));
|
||||
if (match) return path.join(subagentsDir, match);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `server/lib/claude-home.js` | **New** | Claude home directory path management; v2 adds `findSubagentTranscriptPath` prefix fuzzy matching |
|
||||
| `server/routes/sessions.js` | Modified | v1: add `GET /sessions/:id/transcript`; v2: add `GET /sessions/:id/transcripts`, transcript endpoint gains `after`/`before` params and `first_line`/`last_line` response |
|
||||
| `scripts/import-history.js` | Modified | Use `getClaudeHome()` instead of hardcoded path |
|
||||
| `scripts/install-hooks.js` | Modified | Use `getSettingsPath()` instead of hardcoded path |
|
||||
| `server/routes/settings.js` | Modified | Use `getClaudeHome()` for hooks detection |
|
||||
| `client/src/lib/types.ts` | Modified | v1: add `TranscriptMessage`, `TranscriptContent`; v2: add `TranscriptInfo`, `TranscriptListResult`, `TranscriptResult` gains `last_line`/`first_line` |
|
||||
| `client/src/lib/api.ts` | Modified | v1: add `sessions.transcript()`; v2: add `sessions.transcripts()`, `transcript()` gains `after`/`before` params |
|
||||
| `client/src/pages/SessionDetail.tsx` | Modified | Add tab switching and Conversation tab; v2: remove `agents` prop from ConversationView |
|
||||
| `client/src/components/conversation/ConversationView.tsx` | **New** → v2 rewrite | v1: basic pagination; v2: chat-flow mode (WebSocket incremental + scroll-up history + auto-scroll) |
|
||||
| `client/src/components/conversation/MessageList.tsx` | **New** | Message list (with collapsible blocks, command formatting, skill content folding, task notification folding) |
|
||||
| `client/src/components/conversation/ToolCallBlock.tsx` | **New** | Collapsible tool call display |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|----------|
|
||||
| JSONL file doesn't exist | Return `{ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 }`; UI shows "No conversation records found." |
|
||||
| JSONL line parse failure | Skip the line, continue processing remaining lines |
|
||||
| Single content exceeds 10KB | Truncate and append `[truncated]` marker |
|
||||
| Sub-agent JSONL doesn't exist | Same as main file — return empty list |
|
||||
| Session cwd is null | Use `findTranscriptPath()` to scan project directories |
|
||||
| CLAUDE_HOME path invalid | Log warning, return empty list |
|
||||
| Incremental load returns no new messages (v2) | `after` request returns empty array, frontend silently ignores |
|
||||
| History load failure (v2) | Silent failure, doesn't interrupt user experience |
|
||||
| WebSocket disconnection (v2) | Doesn't affect loaded messages; next event after reconnect triggers incremental load |
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Compaction**: After `/compact`, older messages are lost from the JSONL. The viewer only shows what's currently in the file — this is expected behavior. Compact transcripts appear as separate entries in the transcript selector.
|
||||
- **Active sessions**: JSONL may be actively written to. Every request re-reads the file for real-time freshness. WebSocket events trigger incremental loading — no polling needed.
|
||||
- **Unpaired tool_use/tool_result**: Display the tool call without the result section; no error.
|
||||
- **Message order**: JSONL is ordered chronologically; responses preserve the same order (oldest first).
|
||||
- **Database ID vs file ID mismatch (v2)**: Database agent IDs use format `<sessionId>-jsonl-<shortId>`, but JSONL filenames use `agent-<shortId>.jsonl`. v2 bypasses database IDs entirely via the `transcripts` endpoint, which scans the filesystem and uses file short IDs.
|
||||
- **Compaction filename format (v2)**: In the database, compaction agent IDs use format `<sessionId>-compact-<uuid>`, but filenames use `agent-acompact-<hex>.jsonl`. `findSubagentTranscriptPath` supports prefix fuzzy matching for `agent-acompact-*.jsonl`.
|
||||
- **Scroll position preservation (v2)**: When loading history, the scroll position is preserved by computing the `scrollHeight` delta, ensuring the viewport content doesn't jump.
|
||||
- **Duplicate events (v2)**: WebSocket may send multiple `new_event` messages; incremental loading uses `after` line number for deduplication, preventing duplicate appends.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
| Layer | Test Content |
|
||||
|-------|-------------|
|
||||
| API unit tests | `GET /sessions/:id/transcript` — normal response, file not found, invalid session, pagination params, agent_id filtering |
|
||||
| API unit tests | `GET /sessions/:id/transcript` — v2: `after` incremental loading, `before` history loading, `first_line`/`last_line` response |
|
||||
| API unit tests | `GET /sessions/:id/transcripts` — v2: file scanning, compaction type, meta.json reading |
|
||||
| API unit tests | `claude-home.js` — path inference logic, env var override, fallback scanning, compaction prefix fuzzy matching |
|
||||
| Frontend component tests | `MessageList` rendering, `ToolCallBlock` collapse/expand, command formatting, skill content folding |
|
||||
| Frontend component tests | `ConversationView` — v2: initial load, incremental append, history load, scroll detection, new messages indicator |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CLAUDE_HOME` | `~/.claude` | Claude Code home directory (e.g. `~/.codefuse/engine/cc/`) |
|
||||
@@ -0,0 +1,220 @@
|
||||
# Design: Fix Agent-Monitor server memory leak
|
||||
|
||||
- **Date**: 2026-05-22
|
||||
- **Author**: zhihua + Claude (brainstorming collaboration)
|
||||
- **Status**: Design Approved, pending implementation plan
|
||||
|
||||
## Background
|
||||
|
||||
After running `npm start` locally, the server process memory grows continuously over time and eventually exhausts host memory when combined with Claude / IDE / browser. The initial proposal was to deploy Agent-Monitor on a remote server and access it via the local browser, but investigation showed this only relocates the problem — the root cause is in the server itself, and a long-running remote instance will also OOM.
|
||||
|
||||
This design focuses on **root-cause remediation**, not remote deployment. Once memory is stable post-fix, we can revisit whether remote deployment is still desirable.
|
||||
|
||||
## Current diagnosis (with code evidence)
|
||||
|
||||
Measured locally:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| `data/dashboard.db` | 192 MB |
|
||||
| `events` row count | 251,244 |
|
||||
| `sessions` count | 1130 (completed 1015 + abandoned 110 + active 5) |
|
||||
| Largest single event size | 369 KB |
|
||||
| `~/.claude/projects` | 58 MB |
|
||||
|
||||
Three leak / performance sources were identified:
|
||||
|
||||
### Leak #1: TranscriptCache entry has no per-entry size cap
|
||||
|
||||
In `server/lib/transcript-cache.js`, every cache entry holds three push-only arrays:
|
||||
|
||||
- `state.turnDurations.push(...)` (l.327)
|
||||
- `state.errors.push(...)` (l.332, 343)
|
||||
- `state.compaction.entries.push(...)` (l.315)
|
||||
|
||||
`_merge()` incremental merging (l.456, 462, 468) likewise only pushes and never trims.
|
||||
|
||||
`MAX_CACHE_ENTRIES = 200` bounds the number of entries, but **each entry is unbounded in size**. A long session emits one turnDuration per turn (~50 bytes), so a few thousand turns = MB-scale per entry; 200 entries × tens of MB = **multiple GB**.
|
||||
|
||||
### Leak #2: `_set()` stores everything twice (per-entry memory doubled)
|
||||
|
||||
`server/lib/transcript-cache.js:51-58`:
|
||||
|
||||
```js
|
||||
this._set(key, {
|
||||
errors: result?.errors ? [...result.errors] : null, // top-level shallow copy
|
||||
turnDurations: result?.turnDurations ? [...result.turnDurations] : null,
|
||||
compaction: this._cloneCompaction(result.compaction),
|
||||
...
|
||||
result, // contains references to the same fields
|
||||
});
|
||||
```
|
||||
|
||||
The top-level fields are shallow-copied (`[...result.errors]`) new array objects that do not share references with `result.errors`. **Each array exists twice on the heap per cache entry.**
|
||||
|
||||
### Performance issue: the sweep does a full scan over events
|
||||
|
||||
`server/index.js:329` runs every 60-300s:
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT e.session_id, json_extract(e.data,'$.transcript_path') AS tp
|
||||
FROM events e JOIN sessions s ON s.id=e.session_id
|
||||
WHERE s.status='active' AND json_extract(e.data,'$.transcript_path') IS NOT NULL
|
||||
GROUP BY e.session_id ORDER BY MAX(e.id) DESC
|
||||
```
|
||||
|
||||
Doing `json_extract` + DISTINCT + ORDER BY across 250k events rows produces large temporary SQLite memory spikes and is slow.
|
||||
|
||||
## Goals and constraints
|
||||
|
||||
**Goals**:
|
||||
|
||||
1. Server process RSS stays stable over long runs (< 300 MB)
|
||||
2. No Agent log loss (events table remains complete; no retention)
|
||||
3. Reversible changes confined to `server/`; no changes to hook-handler / UI / WebSocket protocol
|
||||
|
||||
**Non-goals** (explicitly out of scope):
|
||||
|
||||
- Remote deployment
|
||||
- Events table retention / archival
|
||||
- DB engine swap / compression / sharding
|
||||
- UI / frontend / CLI changes
|
||||
|
||||
## Design
|
||||
|
||||
### Change A: TranscriptCache per-entry sliding window
|
||||
|
||||
Add a configurable cap:
|
||||
|
||||
```js
|
||||
const MAX_ARRAY_LEN = parseInt(process.env.TRANSCRIPT_CACHE_MAX_ARRAY_LEN, 10) || 1000;
|
||||
```
|
||||
|
||||
After each push in `_streamRange` parsing (l.315/327/332/343 etc.) and in `_merge` incremental merging (l.456/462/468), trim immediately:
|
||||
|
||||
```js
|
||||
if (arr.length > MAX_ARRAY_LEN) arr.splice(0, arr.length - MAX_ARRAY_LEN);
|
||||
```
|
||||
|
||||
Applies to `turnDurations`, `errors`, `compaction.entries`, and `usageExtras.{service_tiers, speeds, inference_geos}` (these Set→Array conversions can also accumulate).
|
||||
|
||||
**Why no data loss**:
|
||||
|
||||
`routes/hooks.js:583, 633` already inserts `result.errors` / `result.turnDurations` into the events table on every hook trigger, with dedup (`SELECT 1 ... WHERE summary=?` / `WHERE created_at=?`). After cache truncation, the next hook re-reads the transcript file → dedup skips existing rows → only new rows are inserted. The events table stays complete.
|
||||
|
||||
**Capacity estimate**:
|
||||
- 1 turn ≈ 50 bytes
|
||||
- 1000 turns = 50 KB / cache entry
|
||||
- 200 entries full ≈ 10 MB
|
||||
|
||||
### Change B: Eliminate `_set()` double storage
|
||||
|
||||
Simplify the cache entry shape:
|
||||
|
||||
```js
|
||||
this._cache.set(key, { mtimeMs, size, bytesRead, result });
|
||||
```
|
||||
|
||||
Drop all top-level `errors` / `turnDurations` / `compaction` / `usageExtras` / `tokensByModel` / `thinkingBlockCount` / `latestModel` fields. `_merge` computes via local variables and writes back only into `result`.
|
||||
|
||||
**Expected effect**: ~50% memory reduction per entry.
|
||||
|
||||
### Change C: Stop sweeping events for transcript_path
|
||||
|
||||
**Schema migration** (`server/db.js`):
|
||||
|
||||
```sql
|
||||
-- Add column (idempotent)
|
||||
ALTER TABLE sessions ADD COLUMN transcript_path TEXT;
|
||||
|
||||
-- One-time backfill (runs once at startup, gated by a .migrations marker file to prevent reruns)
|
||||
UPDATE sessions SET transcript_path = (
|
||||
SELECT json_extract(data,'$.transcript_path') FROM events
|
||||
WHERE events.session_id=sessions.id
|
||||
AND json_extract(data,'$.transcript_path') IS NOT NULL
|
||||
LIMIT 1
|
||||
) WHERE transcript_path IS NULL;
|
||||
```
|
||||
|
||||
Follow the idempotent migration pattern at `server/db.js:284` (the `agents_new` rebuild).
|
||||
|
||||
**Write path** (`server/routes/hooks.js` `ensureSession`):
|
||||
|
||||
When `transcript_path` is first seen, run `UPDATE sessions SET transcript_path=? WHERE id=? AND transcript_path IS NULL`.
|
||||
|
||||
**Sweep query rewrite** (`server/index.js:329`):
|
||||
|
||||
```sql
|
||||
SELECT id, transcript_path FROM sessions
|
||||
WHERE status='active' AND transcript_path IS NOT NULL
|
||||
```
|
||||
|
||||
The query at `server/index.js:309` that fetches `transcript_path` on abandonment is also rewritten to read from the sessions table.
|
||||
|
||||
**Complexity**: drops from O(total events rows) to O(active sessions ≈ single digits). **Not a single events row is removed.**
|
||||
|
||||
## Verification strategy
|
||||
|
||||
### Unit tests (new `server/__tests__/transcript-cache-bounded.test.js`)
|
||||
|
||||
1. With `MAX_ARRAY_LEN=100`, feed 500 turns → `result.turnDurations.length === 100`, tail retained
|
||||
2. After cache truncation, re-extracting → events table dedup skips existing rows, insert count == 0
|
||||
3. Coarse memory assertion: 200 entries × 1000 turns, `process.memoryUsage().heapUsed` delta < 30 MB
|
||||
|
||||
### Integration tests
|
||||
|
||||
- `npm run test:server` green
|
||||
- `npm run test:client` green
|
||||
- `npm run mcp:typecheck` passes
|
||||
|
||||
### Measurement script (one-off)
|
||||
|
||||
New `scripts/memory-soak-test.js`:
|
||||
|
||||
- Generate fake transcript jsonl with 10000 turns
|
||||
- Start the server, simulate 10 concurrent active sessions, fire a hook every 1s
|
||||
- Run for 30 minutes, log `process.memoryUsage().rss` per minute
|
||||
- Assert: RSS growth at minute 30 < 50 MB
|
||||
|
||||
### Verification checklist (pre-merge)
|
||||
|
||||
- [ ] Unit + integration tests green
|
||||
- [ ] `npm run mcp:typecheck` passes
|
||||
- [ ] Local `npm start` for 1h, `ps -o rss=` monitoring shows a flat curve
|
||||
- [ ] DB migration idempotent: two consecutive `npm start` runs without errors
|
||||
- [ ] Old DB (no `transcript_path` column) → migrate + backfill → sweep works
|
||||
- [ ] After cache truncation, the UI events list still shows all old turns/errors
|
||||
|
||||
## Risks and rollback
|
||||
|
||||
### Risk matrix
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| `MAX_ARRAY_LEN=1000` too small for ultra-long sessions | Low | Medium | Env var tunable to 5000-10000; events table is always complete, UI can still query |
|
||||
| Extra dedup SELECTs after cache truncation | Medium | Low | Sweep runs every 60-300s; an extra 100-1000 primary-key lookups per run is acceptable |
|
||||
| ALTER TABLE fails on old DB | Very low | High | Use the migration pattern at `db.js:284` — try-catch + column-existence check |
|
||||
| transcript_path backfill is slow due to events scan | Low | Low | One-time migration takes ~1s; use EXISTS subquery instead of join |
|
||||
| `_set()` shape change breaks other readers | Low | Medium | Grep the repo to confirm all external consumers of `extract()` only read `result.*` |
|
||||
|
||||
### Rollback
|
||||
|
||||
- All changes are confined to `server/`; **hook-handler / UI / WebSocket protocol untouched**
|
||||
- Rollback at any phase = `git revert` of the matching commit
|
||||
- DB schema: `ALTER TABLE ... ADD COLUMN` is not reversible, but an unread/unwritten new column is harmless; once code is reverted, sessions just has an extra empty column
|
||||
|
||||
## Optional follow-ups (out of scope here)
|
||||
|
||||
- Add a `(session_id, event_type, created_at)` composite index on events (UI query performance)
|
||||
- Add a `lastProcessedTurnTimestamp` cursor to the cache so `extract` only returns new turns (eliminates dedup SELECTs entirely)
|
||||
- `/api/internal/memory` diagnostic endpoint returning `cache.stats()` + `process.memoryUsage()`
|
||||
|
||||
## Decision record
|
||||
|
||||
| Option | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Remote deployment vs fix leak | Fix leak | Remote deployment relocates the problem; the leak hits remote too |
|
||||
| Permanent events retention vs retention policy | Permanent | Hard user constraint: guarantee Agent log integrity |
|
||||
| Truncate cache vs not truncate | Truncate to MAX_ARRAY_LEN | Events table already persists raw data; the cache is a derived view |
|
||||
| Delete events vs rewrite the sweep query | Rewrite the query | Satisfies the "no log loss" constraint |
|
||||
| Introduce LRU byte-budget instead of entry count | No | Entry-count cap + per-entry cap is already enough; byte accounting adds complexity |
|
||||
@@ -0,0 +1,256 @@
|
||||
# Tabby — Floating Companion (Design Spec)
|
||||
|
||||
**Date:** 2026-05-28
|
||||
**Status:** Approved (design) — pending spec review before planning
|
||||
**Owner:** Nguyễn Ngọc Trí Vĩ (David)
|
||||
**Topic:** A cute-but-functional cat companion that lives in the dashboard's bottom corner, reacts to live session events, and expands into a panel for status, quick actions, and asking questions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
**Tabby** is a floating cat avatar pinned to the bottom-right corner of the Agent Dashboard on every route. It is two things at once:
|
||||
|
||||
1. **A reactive mascot** — an SVG cat whose face, ears, eyes, and posture react in real time to what the monitored Claude Code sessions are doing (a session finishes → tail-up, eyes `^^`; an error/hook fails → arch + ears-back; idle → curls up asleep). Eyes track the cursor when alert.
|
||||
2. **An assistant** — click the avatar (or press `⌘B` / `Ctrl+B`) to expand a panel with a live status line, quick navigation actions, and an **Ask** box that answers simple questions from cached dashboard data, with a handoff to the existing **Run** page to ask Claude for real.
|
||||
|
||||
The "do the job" path reuses what already exists: `POST /api/run` spawns a real `claude` subprocess and streams over WebSocket. Tabby does **not** introduce any new LLM backend, API key, or server route in P1/P2. P3 adds a single client-only deep-link prefill.
|
||||
|
||||
Name **Tabby** matches the app's identity: this is a **Monitor** ("watching your agents"), and Tabby is the alert watcher curled in the corner.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
- Delightful, on-theme personality layer over live session data — "cute but does the job."
|
||||
- Always-present, low-footprint corner avatar that auto-surfaces notable events as transient speech bubbles, then settles.
|
||||
- One-keystroke (`⌘B`) expand to a functional panel: status, quick actions, local Ask.
|
||||
- Reuse the existing event stream (`eventBus`) and Run flow — no new backend in P1/P2.
|
||||
- Fully consistent with the existing dark Tailwind theme (`surface-*`, `accent`, `border`).
|
||||
- Accessible: keyboard-operable, `aria-live` bubbles, honors `prefers-reduced-motion`.
|
||||
- Degrades safe: if WebSocket is down/delayed, Tabby shows a calm/disconnected state — never errors, never blocks the page.
|
||||
|
||||
### Non-Goals (YAGNI)
|
||||
- No drag-to-reposition (fixed bottom-right).
|
||||
- No sound effects.
|
||||
- No new LLM/chat backend or API key (Ask is rule-based locally; real Claude = handoff to Run).
|
||||
- No server-side persistence (preferences in `localStorage` only).
|
||||
- No multi-avatar / skins / customization.
|
||||
- No changes to existing pages beyond the minimal mount + the P3 Run prefill.
|
||||
|
||||
---
|
||||
|
||||
## 3. Where it lives (architecture)
|
||||
|
||||
```
|
||||
App.tsx
|
||||
└─ useWebSocket(onMessage = eventBus.publish) // single shared socket, already exists
|
||||
└─ Layout.tsx
|
||||
├─ UpdateNotifier (existing global floater)
|
||||
├─ Tabby ◀── NEW: mounted here, sibling of UpdateNotifier
|
||||
└─ <Outlet/> (page routes)
|
||||
```
|
||||
|
||||
- **Mount point:** `client/src/components/Layout.tsx`, right next to `<UpdateNotifier/>`. This guarantees Tabby persists across every route and shares the one WebSocket connection.
|
||||
- **Data source:** the existing `eventBus` (`client/src/lib/eventBus.ts`).
|
||||
- `eventBus.subscribe(handler)` → every `WSMessage`.
|
||||
- `eventBus.onConnection(handler)` + `eventBus.connected` → WS up/down.
|
||||
- No prop drilling, no new context provider. The brain hook subscribes directly.
|
||||
- **Navigation:** quick actions use `react-router` (`useNavigate`) to jump to existing routes (`/sessions`, `/sessions/:id`, `/activity`, `/run`).
|
||||
|
||||
### Component layout (new, isolated directory)
|
||||
|
||||
```
|
||||
client/src/components/Tabby/
|
||||
Tabby.tsx # Container. Owns open/collapsed/muted state, ⌘B + Esc handlers,
|
||||
# localStorage persistence. Composes the three presentational parts.
|
||||
CatAvatar.tsx # Pure presentational SVG cat. Props: { mood, eyeTarget, reducedMotion }.
|
||||
# No data access — fully testable in isolation.
|
||||
SpeechBubble.tsx # Transient bubble. Props: { text, onDismiss }. aria-live="polite",
|
||||
# auto-dismiss ~4.5s. No data access.
|
||||
TabbyPanel.tsx # Expanded panel: status header + quick actions + Ask box.
|
||||
# Receives status summary + handlers as props.
|
||||
useTabbyBrain.ts # The brain. Subscribes eventBus → derives { mood, statusSummary,
|
||||
# bubbleQueue }. Owns all timers (idle/sleep/stuck). The only unit
|
||||
# that touches eventBus.
|
||||
intents.ts # Local Ask: maps a free-text question → templated answer from cached
|
||||
# status, or a { runHandoff: prompt } signal. Pure function.
|
||||
quips.ts # mood/event → randomized phrase pool. The personality. Pure data + picker.
|
||||
tabby.css # Keyframes (breathe/blink/ear-twitch/arch/tail-flick), translucency,
|
||||
# prefers-reduced-motion overrides.
|
||||
```
|
||||
|
||||
**Boundaries / contracts:**
|
||||
- `useTabbyBrain` is the *only* unit that subscribes to `eventBus`. Everything else receives plain props. This keeps the live-data surface in one place and the rest trivially testable.
|
||||
- `CatAvatar`, `SpeechBubble`, `TabbyPanel` are pure presentational components — given props, render UI. No side effects.
|
||||
- `intents.ts` and `quips.ts` are pure functions over inputs — unit-testable with no DOM.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data flow
|
||||
|
||||
```
|
||||
server broadcast ──► useWebSocket ──► eventBus.publish ──► useTabbyBrain subscriber
|
||||
│
|
||||
(reduce WSMessage + timers into state)│
|
||||
▼
|
||||
{ mood, statusSummary, bubbleQueue }
|
||||
│
|
||||
┌──────────────────────────────┬──────────────┴───────────────┐
|
||||
▼ ▼ ▼
|
||||
CatAvatar(mood) SpeechBubble(next bubble) TabbyPanel(statusSummary)
|
||||
│
|
||||
quick action │ Ask
|
||||
▼
|
||||
useNavigate(route) | intents() → answer
|
||||
| or → /run?prompt=
|
||||
```
|
||||
|
||||
`useTabbyBrain` maintains a small in-memory model derived from the stream (it does not refetch):
|
||||
- `liveCount` — active sessions/agents currently working.
|
||||
- `errorCount` — sessions/agents in error since last clear.
|
||||
- `lastEventAt` — timestamp of most recent `new_event`/update (drives `stuck`/`sleeping`).
|
||||
- `connected` — from `eventBus.onConnection`.
|
||||
- `recentDone` — transient flag set on a `session_updated` → status `completed`, cleared after the happy animation.
|
||||
|
||||
The exact `WSMessage.type` union the brain switches on (from `client/src/lib/types.ts`):
|
||||
`session_created`, `session_updated`, `agent_created`, `agent_updated`, `new_event`,
|
||||
`import.progress`, `update_status`, `run_stream`, `run_status`, `run_input_ack`, `cc_config_changed`.
|
||||
Tabby only cares about: `session_created`/`session_updated`/`agent_created`/`agent_updated` (mood + counts),
|
||||
`new_event` (activity heartbeat → `lastEventAt`, and hook-failure detection via the event payload),
|
||||
`run_status` (run finished → `happy`). The rest are ignored.
|
||||
|
||||
These feed both the avatar mood and the panel's status line. Counts are best-effort from the stream; the panel may also read a one-shot from existing stats endpoints if needed for an accurate initial number (open item — see §10).
|
||||
|
||||
---
|
||||
|
||||
## 5. Mood state machine (rule-based brain)
|
||||
|
||||
Mood is a pure function of `(streamModel, timers)`, evaluated on every relevant event and on timer ticks. **Highest-priority matching state wins:**
|
||||
|
||||
| Priority | Mood | Trigger | Cat expression |
|
||||
|---------:|------|---------|----------------|
|
||||
| 1 | `disconnected` | WS down (`eventBus.connected === false`) | faded/desaturated, flat ears, still |
|
||||
| 2 | `worried` | `session_updated`/`agent_updated` with status `error`, or a hook-failure `new_event` | arch + puff, ears back, brow down, brief shake |
|
||||
| 3 | `stuck` | ≥1 live session AND `now - lastEventAt > STUCK_MS` | ears-up alert stare, `!` |
|
||||
| 4 | `happy` | `session_updated` → `completed`, or `run_status` finished (transient, ~4s) | tail-up, eyes `^^`, head-bob |
|
||||
| 5 | `thinking` | Ask in flight (panel) | head-tilt, `…` |
|
||||
| 6 | `watching` | ≥1 live session, recent activity | eyes track cursor, ears up, tail flick |
|
||||
| 7 | `sleeping` | no activity AND idle `> SLEEP_MS` | curled, eyes shut, `zzz` |
|
||||
| 8 | `idle` | default / fallback | slow blink, gentle breathe |
|
||||
|
||||
Constants (tunable, defined in `useTabbyBrain`): `STUCK_MS` (~10 min), `SLEEP_MS` (~3 min). All timers cleared on unmount.
|
||||
|
||||
**Event → mood mapping (concrete):**
|
||||
- `onConnection(true)` → recompute (leaves `disconnected`).
|
||||
- `onConnection(false)` → `disconnected`.
|
||||
- `session_updated` data.status `error` → `worried` (+ increment `errorCount`).
|
||||
- `session_updated` data.status `completed` → `happy` (transient) + decrement `liveCount`.
|
||||
- `session_created` / `session_updated` data.status `active` → `watching`, recompute `liveCount`.
|
||||
- `agent_updated` status `error` → `worried`.
|
||||
- `new_event` → refresh `lastEventAt`; hook-failure event types (confirm in build, see §10) → `worried`.
|
||||
- `run_status` finished → `happy` (transient).
|
||||
- (timers) inactivity → `stuck` (if live) or `sleeping` (if not).
|
||||
|
||||
---
|
||||
|
||||
## 6. Eyes & motion
|
||||
|
||||
- **Eye tracking (`watching`/`idle`):** pupils follow the mouse, clamped inside the eye socket via a small vector-normalize + clamp. Throttled (rAF or ~30ms) to stay cheap.
|
||||
- **On event:** eyes glance toward the bubble, then relax back to tracking.
|
||||
- **Ears/tail/body:** CSS keyframe animations in `tabby.css`, swapped by a `data-mood` attribute on the avatar root.
|
||||
- **`prefers-reduced-motion`:** static eyes (centered), no breathe/shake/arch — mood still conveyed via static pose + face. Detected via `matchMedia`, passed as `reducedMotion` prop.
|
||||
|
||||
---
|
||||
|
||||
## 7. Auto-surface (speech bubbles)
|
||||
|
||||
- Pipeline: event → `quips.pick(mood/event)` → enqueue bubble → show ~4.5s → dismiss → settle.
|
||||
- **Rate limit:** at most one bubble every few seconds; coalesce bursts ("3 sessions finished" instead of three bubbles).
|
||||
- **Mute toggle:** persisted in `localStorage`. Muted = no bubbles, but faces/animations still react. Toggle lives in the panel.
|
||||
- **Accessibility:** bubble container is `aria-live="polite"` so screen readers announce notable events without stealing focus.
|
||||
|
||||
Example quips (from `quips.ts`, randomized):
|
||||
- happy: "session wrapped 🐾", "nice, that one's done", "4m12s — clean run"
|
||||
- worried: "ow, an error", "a hook tripped — peek?"
|
||||
- stuck: "this one's been quiet a while…", "still chewing on something?"
|
||||
- sleeping: "zzz", "wake me if something happens"
|
||||
|
||||
---
|
||||
|
||||
## 8. Panel (click / ⌘B)
|
||||
|
||||
Opens as a small card anchored above the avatar. Themed with `surface-3`/`border`/`accent`.
|
||||
|
||||
**Status header:** `🐾 N live · M errored · ●connected` (from brain's `statusSummary`; `●` reflects WS state, colored by health).
|
||||
|
||||
**Quick actions** (each = `useNavigate` to an existing route, or a local toggle):
|
||||
- Jump to errored session → `/sessions/:id` (most recent error) or `/sessions?status=error`.
|
||||
- Active sessions → `/sessions` (or `/activity`).
|
||||
- **Run Claude** → `/run`.
|
||||
- Activity feed → `/activity`.
|
||||
- Mute / unmute bubbles (local toggle, persisted).
|
||||
- Clear alerts (reset `errorCount`).
|
||||
|
||||
**Ask box:**
|
||||
- P1/P2: `intents()` matches the query against a small set of local intents over cached status — e.g. *what's running*, *any errors*, *how many today*, *slowest* — and returns a templated answer rendered in the panel.
|
||||
- Unmatched query → offer: "Ask Claude directly?" → opens `/run?prompt=<query>` (P3).
|
||||
|
||||
**Dismiss:** `Esc`, click-outside, or re-press `⌘B`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Phasing
|
||||
|
||||
### P1 — Mascot (delight, zero backend)
|
||||
- `CatAvatar.tsx` (full SVG + all moods + eye tracking + reduced-motion).
|
||||
- `useTabbyBrain.ts` (eventBus subscription, mood machine, timers, bubble queue).
|
||||
- `SpeechBubble.tsx`, `quips.ts`, `tabby.css`.
|
||||
- `Tabby.tsx` container mounting avatar + bubble; `⌘B` reserved but panel stubbed.
|
||||
- Mounted in `Layout.tsx`.
|
||||
- **Outcome:** living, reacting cat in the corner with auto-bubbles. No panel yet.
|
||||
|
||||
### P2 — Panel (functional)
|
||||
- `TabbyPanel.tsx`: status header + quick actions (router nav) + local Ask.
|
||||
- `intents.ts` local intent matching.
|
||||
- `localStorage` for `collapsed` + `muted`; mute/clear in panel.
|
||||
- `Settings.tsx`: a single on/off toggle for Tabby (persisted), read by `Tabby.tsx`.
|
||||
- **Outcome:** click/⌘B opens a useful panel; Ask answers from local data.
|
||||
|
||||
### P3 — "Do the job" handoff
|
||||
- `Run.tsx`: read `?prompt=` search param → `setPrompt(prefill)` on mount (mirrors the existing `?session=` pattern). Client-only, no server change.
|
||||
- Wire Ask's unmatched-query path → `/run?prompt=<query>`.
|
||||
- **Outcome:** Tabby can hand a real question to a real `claude` subprocess via the existing Run flow.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open items (resolve during planning/build)
|
||||
1. **Accurate initial counts:** the stream gives deltas; on first mount counts are unknown until events arrive. Decide: (a) start at 0 and let the stream fill in (simplest), or (b) one-shot read from the existing stats endpoint for an accurate seed. Leaning (a) for P1, optional (b) in P2 panel.
|
||||
2. **Hook-failure detection:** confirm which `event` `eventType` values represent hook failures vs. normal lifecycle, so `worried` only fires on real problems. Verify against `server/routes/hooks.js` + DB event types during build.
|
||||
3. **Errored-session deep link:** confirm `/sessions` supports a `status=error` query or whether to navigate to the specific `/sessions/:id`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Theme & accessibility notes
|
||||
- Colors strictly from existing tokens: `surface-0..5`, `border`/`border-light`, `accent`/`accent-hover`. Cat palette: warm accent-tinted body that reads on the dark `surface-0` background; soft glow via `accent-muted`.
|
||||
- Fonts inherit (`Inter` / `JetBrains Mono`) — bubble/status text uses existing classes.
|
||||
- Keyboard: `⌘B`/`Ctrl+B` toggle, `Esc` close, panel actions tab-focusable.
|
||||
- `prefers-reduced-motion`: disables continuous animation.
|
||||
- z-index above content, below modals; never traps focus when collapsed.
|
||||
|
||||
---
|
||||
|
||||
## 12. Verification (per CLAUDE.md)
|
||||
- **Frontend:** `npm run test:client`.
|
||||
- Unit tests for `useTabbyBrain` mood transitions (each event → expected mood, priority ordering, timer-driven `stuck`/`sleeping`).
|
||||
- Unit tests for `intents()` (known queries → templated answers; unknown → runHandoff).
|
||||
- Unit test for `quips.pick` (returns a string for every mood).
|
||||
- **No server change in P1/P2** → `npm run test:server` not required for those phases. P3 touches only `Run.tsx` (client) → still client-only; run `test:client`.
|
||||
- Manual: load dashboard, trigger a run, observe mood/bubble transitions; toggle reduced-motion; toggle mute; ⌘B/Esc.
|
||||
|
||||
---
|
||||
|
||||
## 13. File change summary
|
||||
**New:** `client/src/components/Tabby/{Tabby,CatAvatar,SpeechBubble,TabbyPanel}.tsx`, `client/src/components/Tabby/{useTabbyBrain.ts,intents.ts,quips.ts,tabby.css}`, plus `__tests__` for brain/intents/quips.
|
||||
**Edited:** `client/src/components/Layout.tsx` (mount, P1) · `client/src/pages/Settings.tsx` (on/off toggle, P2) · `client/src/pages/Run.tsx` (`?prompt=` prefill, P3) · i18n files (`tabby:*` keys, as strings are added).
|
||||
@@ -0,0 +1,74 @@
|
||||
# Stage auto-detection — design
|
||||
|
||||
**Status:** approved 2026-07-28. Sub-project B of three (C = worktree lanes, shipped on `feat/worktree-lanes`; A = merged Workspace page, next). Built after C because C settled the lane data model.
|
||||
|
||||
## Problem
|
||||
|
||||
A lane's stage only moves when the driving agent calls `ccam stage <name>`. Every un-instrumented session — which is most of them — sits at `idle` forever while its agent works, so the pipeline map shows nothing. The dashboard already receives every tool call the agent makes; it just never reads them.
|
||||
|
||||
## Goal
|
||||
|
||||
Infer a lane's stage from the hook stream it already ingests, and show it **without ever claiming it as evidence**.
|
||||
|
||||
## The rule that shapes everything
|
||||
|
||||
**Inference never renders green.** A node the dashboard inferred reaches `passed-no-evidence` (amber) at most; `done` requires a declared stage carrying evidence via `ccam stage --evidence`. If inference could paint a node green, the amber/green distinction — the reason this feature exists — would be worthless.
|
||||
|
||||
Declared always outranks detected. A lane that has declared `review` ignores a detection for `implement`.
|
||||
|
||||
## Signals that actually exist here
|
||||
|
||||
Verified against a real 121 MB install before designing:
|
||||
|
||||
| Source | Rows | Notes |
|
||||
|---|---|---|
|
||||
| `events.tool_name` | Bash 29 470, Read 20 236, Edit 4 678, Write 1 330, Agent 1 188, TaskUpdate 908, Skill 127 | the bulk of the signal |
|
||||
| `events.data.tool_input` | present on every `PostToolUse` | full Bash command strings, Edit/Write paths, Skill names |
|
||||
| `TodoWrite` | **0** | this Claude Code build uses `TaskCreate`/`TaskUpdate` instead — do not design around TodoWrite |
|
||||
|
||||
So the signal is `tool_name` plus a regex over `tool_input`. No model call, no extra query.
|
||||
|
||||
## Where the rules live
|
||||
|
||||
In the pipeline template, not in code. `server/data/pipelines/default.json` gains an optional `detect` array per node:
|
||||
|
||||
```json
|
||||
{ "id": "tests", "detect": [{ "tool": "Bash", "match": "\\b(npm (run )?test|pytest|vitest|jest|go test|cargo test)\\b" }] }
|
||||
```
|
||||
|
||||
A rule is `{tool, match?}`: `tool` matches `events.tool_name` exactly; `match` is a regex tested against a flattened string of the tool's input. A rule with no `match` fires on the tool alone. A custom template supplied through `DASHBOARD_PIPELINES_DIR` may define its own rules, so a team can teach the dashboard their own conventions without touching the code.
|
||||
|
||||
## Where it runs
|
||||
|
||||
`server/lib/stage-detect.js` exposes one pure function, `detect(pipeline, event) → {nodeId, signal} | null`. It is called from the existing fail-safe block in `touchLaneFromHook` (`server/routes/hooks.js`) that already resolves the lane — no new pass over the hook path, no new query, and the same swallow-everything guarantee, because a hook must never fail on account of bookkeeping.
|
||||
|
||||
## Anti-flapping
|
||||
|
||||
- **Forward only.** A detection whose node index is not greater than the current detected index is dropped. Reading a file after editing it must not pull a lane back to `plan`.
|
||||
- **Write only on change.** Bash alone accounts for 29 470 rows in a real install; the lane row is written only when the detected node actually advances.
|
||||
- **Declared wins.** If the lane's declared stage sits at or beyond the detection, nothing is written.
|
||||
|
||||
## Data model
|
||||
|
||||
Three additive columns on `lanes`, each behind its own `try { SELECT col } catch { ALTER }` probe so a partial migration self-heals: `detected_stage`, `detected_signal`, `detected_at`. `stage` keeps its exact current meaning — the declared stage.
|
||||
|
||||
`lanePayload` gains `detected_stage`, `detected_signal`, and a per-node `detected: boolean` inside `pipeline_nodes`.
|
||||
|
||||
## What the user sees
|
||||
|
||||
A detected node renders amber with a **dashed** border, distinguishing it from an amber solid node (declared without evidence). Its tooltip names the signal: `tests ← npm run test:server`. The lane card shows `auto: tests` when the detection is ahead of the declaration. The header line still shows the declared stage, because that is what the agent asserted.
|
||||
|
||||
## Deliberately not in scope
|
||||
|
||||
- No inference of `done`, and no inference of gate results. A gate is a judgement; only an agent may claim one.
|
||||
- No back-filling of history. Detection starts when this ships; existing lanes gain nothing retroactively.
|
||||
- No inference from `workflows.phases`. It exists and would work, but it covers only Workflow-tool runs and would need its own reconciliation with the declared stage — a separate feature if wanted.
|
||||
- No writing to `stage`. Ever. Detection lives in its own columns so that turning the feature off loses nothing.
|
||||
|
||||
## Testing
|
||||
|
||||
- Rule matcher: one test per shipped rule, plus a rule with no `match`, an invalid regex in a template (must be skipped, not crash the hook), and a tool the rules do not mention.
|
||||
- Monotonic guard: an out-of-order detection is dropped; a same-node detection writes nothing.
|
||||
- Declared precedence: a lane declared at `review` ignores an `implement` detection.
|
||||
- Fail-safety: a malformed event cannot throw out of the hook path.
|
||||
- **Inference never renders green:** given a lane with only detections and no declarations, no node in `pipeline_nodes` may have state `done`. This is the test that guards the feature's whole premise.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Merged Workspace page — design
|
||||
|
||||
**Status:** approved 2026-07-28. Sub-project A of three (C = worktree lanes, shipped; B = stage detection, planned). Built last because it consumes both.
|
||||
|
||||
## Problem
|
||||
|
||||
Lanes and Run are two pages that describe the same activity. `Run` spawns a `claude` process in a directory and streams its output; `Lanes` shows what a lane is doing and where it is in its pipeline. A user watching an agent work has to hold both in their head, and the lane card cannot even send a prompt — `start` opens a promptless conversation run and `message` has no input field.
|
||||
|
||||
## Goal
|
||||
|
||||
One page. A lane strip across the top, the selected lane's pipeline beneath it, and that lane's Claude console below — with every capability the Run page has today.
|
||||
|
||||
## Decisions already taken
|
||||
|
||||
- **The merged page lives at `/run`.** `/lanes` redirects there. One sidebar entry.
|
||||
- **Every run belongs to a lane.** Choosing a working directory that no lane owns creates one (`kind='adopted'`) rather than running loose. A lane is, after all, just a working directory the dashboard is watching.
|
||||
- **Everything from Run survives:** slash-command autocomplete in the prompt editor, model / permission-mode / effort selectors, the token meter with cost, run history, and attach-to-a-live-run.
|
||||
- **Layout:** lane strip (horizontal, scrollable, with the counters and Add) → pipeline map of the selected lane → console. Selecting a lane switches both the pipeline and the console.
|
||||
|
||||
## Architecture
|
||||
|
||||
`client/src/pages/Run.tsx` is 3658 lines holding an envelope model, a merge/typewriter engine, a slash-autocomplete prompt editor, a token meter, cwd suggestions, run history and the page shell. It is extracted into pieces that the new page composes:
|
||||
|
||||
| Unit | Responsibility |
|
||||
|---|---|
|
||||
| `client/src/hooks/useRunStream.ts` | envelope state for one run id: subscribe `run_stream` / `run_status` / `run_input_ack`, merge envelopes, typewriter |
|
||||
| `client/src/components/run/RunConsole.tsx` | render the envelope stream, the prompt editor with slash autocomplete, the token meter, stop/clear |
|
||||
| `client/src/components/run/RunSetup.tsx` | mode / model / permission / effort / cwd / resume pickers, binary status, the limitations banner |
|
||||
| `client/src/components/run/RunHistory.tsx` | past runs, live runs, attach |
|
||||
| `client/src/pages/Workspace.tsx` | lane strip + `PipelineMap` + the three above |
|
||||
|
||||
**The extraction is mechanical and must not change behaviour.** Each unit moves in its own commit with the existing Run tests passing untouched except for import paths. Only once `Run.tsx` is a thin composition does the new page get built. Extraction and composition never share a commit — that is the difference between a reviewable refactor and an unreviewable rewrite.
|
||||
|
||||
## Server glue
|
||||
|
||||
Four small pieces, each independently useful:
|
||||
|
||||
1. **Runs start through the lane.** The UI always calls `POST /api/lanes/:id/start`, which already exists, sits behind the same-origin guard, and records `run_id` on the lane. `POST /api/run` stays for the CLI and other callers; the UI simply stops using it. Lane `start` gains `mode` so a headless one-shot is still possible.
|
||||
2. **`POST /api/lanes/ensure`** — `{cwd, title?}` returns the lane owning that path or creates an `adopted` one. Avoids the UI having to catch a 409 and re-read, and keeps the create-then-start pair from racing.
|
||||
3. **`dashboard_runs.lane_id`** — one additive column, set when a run is started through a lane, so history can be filtered per lane instead of guessed at by `cwd`.
|
||||
4. **A finished run releases its lane.** Today nothing clears `lanes.run_id` when a run ends on its own: the lane reads `running` forever and `message` keeps targeting a dead run. The run-spawner already knows the moment of exit (`actualExitedAt`, added on the worktree branch); on that event, clear the owning lane's `run_id` and set its status back to `idle`. This is a bug the merge exposes rather than causes.
|
||||
|
||||
## What the console must not do
|
||||
|
||||
**It never touches the lane's stage.** Typing `/code-review` in the UI does not move the lane to `review`; only `ccam stage` declares, and only detection (sub-project B) infers. The console is a window onto a process, not a driver of the pipeline. Keeping that boundary is what stops the pipeline from becoming a lie.
|
||||
|
||||
## Risks and how they are contained
|
||||
|
||||
- **The extraction is the whole risk.** 3658 lines, one of them the typewriter engine, with a screens snapshot over the page. Containment: one unit per commit, tests untouched but for imports, snapshot diffs read rather than regenerated, and the composition deferred until the last extraction is green.
|
||||
- **Two consoles for one lane.** Only one run is live per lane (`start` 409s when one exists), so the console shows exactly one stream.
|
||||
- **A lane created just to try a command** leaves an `adopted` lane behind. Acceptable: `adopted` lanes are never destroyable, forgetting one is a click, and the alternative — runs that belong to nothing — is what this design set out to remove.
|
||||
|
||||
## Testing
|
||||
|
||||
- Each extraction: the existing Run tests pass with only import changes, and the screens snapshot for `/run` is unchanged until the page itself changes.
|
||||
- `useRunStream`: envelopes merge in order; a `run_status` terminal event stops the stream; the subscription is disposed on unmount.
|
||||
- `POST /api/lanes/ensure`: returns the existing lane for a path already owned, for a path nested inside one, and creates exactly one lane under concurrent calls.
|
||||
- Run-exit releases the lane: after a run ends by itself, the lane's `run_id` is null and its status is `idle`.
|
||||
- The console does not move the stage: after a full run through the console, the lane's `stage` is what it was.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Worktree-backed lanes + Shipyard-style lifecycle — design
|
||||
|
||||
**Status:** approved 2026-07-28. Sub-project C of three (B = stage auto-detection, A = merged Workspace page) — each gets its own spec, plan and execution cycle. C is being built first because it fixes the lane data model that the other two build on.
|
||||
|
||||
## Problem
|
||||
|
||||
A lane today is a pointer at a directory that already exists. Two agents working in parallel therefore work in the *same* checkout and collide — the exact failure Shipyard solves by giving every lane its own clone. CCAM has no provisioning at all: no way to create a lane's working copy, no way to reset it between features, no way to remove it, and no way to reclaim the database a finished lane leaves behind (a real install reached 121 MB).
|
||||
|
||||
## Goals
|
||||
|
||||
- A lane can own a **git worktree** that CCAM creates, resets and removes.
|
||||
- The lifecycle verbs mirror Shipyard's, because that vocabulary is proven: `add`, `clear`, `reset`, `remove`, plus `purge` as CCAM's analogue of Shipyard's per-lane `dropdb`.
|
||||
- Every destructive action is confirmed **against counted facts**, not adjectives.
|
||||
- Directories the user already had must be impossible for CCAM to destroy.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No dependency bootstrap. A fresh worktree has no `node_modules`, no `.env`, no `.claude/settings.local.json` — all gitignored. Shipyard solves this with per-project `bootstrap`/`migrate`/`seed` hooks, which is a whole subsystem. Out of scope; documented as a limitation.
|
||||
- No per-lane ports, databases, or Docker services.
|
||||
- No orchestration. Unchanged from the existing feature: the driving Claude session declares its own stage.
|
||||
- `VACUUM` is not part of `purge` (see Database reclamation).
|
||||
|
||||
## Data model
|
||||
|
||||
Additive columns on `lanes`, each guarded by the repo's `try { SELECT col } catch { ALTER }` probe:
|
||||
|
||||
| column | meaning |
|
||||
|---|---|
|
||||
| `kind` | `adopted` \| `managed`. Default `adopted`, so every pre-existing row migrates into the safe class. |
|
||||
| `source_repo` | absolute path of the checkout a managed worktree was created from |
|
||||
| `base_branch` | the branch the worktree was cut from, e.g. `development` |
|
||||
| `slug` | sanitised from the title; used for both the directory and the branch name |
|
||||
|
||||
`branch`, `cwd`, `stage`, `stages` and the rest keep their current meaning. `cwd` stays `UNIQUE`.
|
||||
|
||||
Environment: `LANES_ROOT` (default `~/.claude/ccam-lanes`), `LANE_BASE_BRANCH`, `LANE_BRANCH_PREFIX` (default `feat/`).
|
||||
|
||||
Layout: worktree at `$LANES_ROOT/<repo-basename>__<slug>`, branch `<prefix><slug>`. Numbered `lane1..lane9` slots were considered and rejected — CCAM is multi-repo, and slot numbers carry no meaning without Shipyard's per-lane ports.
|
||||
|
||||
## Safety model
|
||||
|
||||
`adopted` lanes expose no destructive verb. No reset, no remove-with-worktree, no branch deletion. The UI hides those controls; the API refuses them.
|
||||
|
||||
A `managed` lane may be destroyed only when **all three** independent checks pass:
|
||||
|
||||
1. `kind === 'managed'`
|
||||
2. the lane's `cwd`, fully resolved (symlinks included), lies inside `LANES_ROOT`
|
||||
3. `git worktree list --porcelain` run in `source_repo` actually lists that path
|
||||
|
||||
Shipyard gets away with one check (`case "$DIR" in */lane$N`) because its directory names are fixed. Dropping numbered slots costs that guarantee, so three cheaper checks replace it. Every destructive function in `server/lib/worktree.js` re-runs the three checks itself rather than trusting its caller.
|
||||
|
||||
CCAM never runs `rm -rf` on a lane. Removal goes through `git worktree remove`; if git refuses, the error surfaces unchanged.
|
||||
|
||||
## Verbs
|
||||
|
||||
| verb | steps | destructive |
|
||||
|---|---|---|
|
||||
| `add` | resolve base → `git worktree add -b <prefix><slug> <dir> <base>` → insert lane row `kind=managed` | no |
|
||||
| `adopt` | today's `POST /api/lanes` — point a lane at an existing directory, `kind=adopted` | no |
|
||||
| `clear` | reset stage/status fields only (already implemented) | no |
|
||||
| `reset` | kill and await the run → `git fetch origin --prune` → checkout base → `reset --hard <base>` → `clean -fd` → delete the feature branch → recreate it from base → clear lane state | **yes** |
|
||||
| `remove` | kill and await the run → unlock if locked → `git worktree remove --force` → `git worktree prune` → delete the branch → delete the lane row | **yes** |
|
||||
| `purge` | delete the lane's sessions and their events plus the orphan `token_usage` rows | **yes** |
|
||||
|
||||
`clean -fd` deliberately omits `-x`, exactly as Shipyard does: gitignored files (`node_modules`, `.env`) survive a reset, untracked-but-not-ignored files do not.
|
||||
|
||||
Branch deletion never touches `main`, `master`, or the lane's `base_branch`, and only runs after the worktree holding that branch is gone.
|
||||
|
||||
## Preflight
|
||||
|
||||
`GET /api/lanes/:id/preflight?action=reset|remove|purge` returns counted facts, never prose:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "reset",
|
||||
"lane": 3, "branch": "feat/criteria-form", "kind": "managed",
|
||||
"dirty": 4, "untracked": 11, "unpushed": 2,
|
||||
"head": "9b3e74a",
|
||||
"blocked": ["unpushed-commits"],
|
||||
"warnings": ["no-remote"]
|
||||
}
|
||||
```
|
||||
|
||||
For `purge`: `sessions`, `events`, `tokenRows`, `bytesEstimate`, and `activeSessionSkipped`.
|
||||
|
||||
The confirmation modal renders those numbers. The action then re-verifies: the client echoes back the `head` and counts it was shown, and the server returns `409` if they moved. `unpushed > 0` blocks `reset`, and `remove` when a managed worktree is actually at risk, unless the request carries `{force: true}`. `unpushed` counts what the action would really discard — with no remote that is `<base_branch>..HEAD`, this lane's own work, not the repository's whole history.
|
||||
|
||||
## Concurrency
|
||||
|
||||
One mutex per lane serialises destructive actions, mirroring `repo_lock()` in the AutomaticWorkflow bot — where concurrent git operations on a shared checkout produced a real `git checkout` exit 128, not a theoretical one. A destructive action first kills the lane's run and **awaits its exit** before touching git.
|
||||
|
||||
`add` can take seconds on a large repo, so it returns `202` with `status=provisioning` and finishes in the background, broadcasting `lane_update` on completion — the same pattern Shipyard uses to drive its spinner.
|
||||
|
||||
## Edge cases and their resolutions
|
||||
|
||||
- **Branch already exists:** if unused, `git worktree add` without `-b`; if checked out elsewhere, refuse and name the other path. Slug collisions get a `-2` suffix.
|
||||
- **Base branch missing on origin:** resolve `origin/<base>` → local `<base>` → the source repo's current HEAD.
|
||||
- **Source repo is itself a worktree, or bare:** works; git resolves through `--git-common-dir`.
|
||||
- **Repo with no commits:** `worktree add` fails and the lane lands `failed` with git's stderr in `notes`. Preflight does not pre-empt it — the lane is created first, then provisioning reports the git failure, and the row is forgotten with `DELETE /api/lanes/:id`.
|
||||
- **Worktree directory deleted by hand:** the lane reports `missing` and only `remove` is offered, taking the prune path.
|
||||
- **`cwd` uniqueness:** `remove` deletes the row, so re-adding the same slug is clean; `reset` keeps the path.
|
||||
|
||||
## Database reclamation
|
||||
|
||||
`events` cascades from `sessions`, but `token_usage` has no foreign key — `purge` must delete those rows explicitly or leave orphans. The currently-live session is never purged.
|
||||
|
||||
SQLite does not shrink on `DELETE`. `purge` runs `DELETE` plus `PRAGMA optimize` and reports the reclaimable size; `VACUUM` is a separate, explicitly-labelled maintenance action because it locks the whole database for seconds. Hiding a database-wide lock inside a button labelled "clean up" would be a trap.
|
||||
|
||||
## Surfaces
|
||||
|
||||
**API:** `POST /api/lanes/worktree` (add), `GET /api/lanes/:id/preflight`, and `reset` / `purge` joining the existing `POST /api/lanes/:id/:action` set, all behind the existing same-origin guard.
|
||||
|
||||
**CLI:** `ccam lanes add --repo <path> [--title <t>] [--base <branch>]`, `ccam lanes reset|remove|purge <id> [--force]`.
|
||||
|
||||
**UI:** a `managed` / `adopted` badge on the lane card; destructive buttons rendered only for `managed`; the existing `ConfirmModal` showing the preflight table.
|
||||
|
||||
## Testing
|
||||
|
||||
Against a real git repository fixture created in a temp directory — no mocks, because every bug worth catching here lives in git's actual behaviour:
|
||||
|
||||
- worktree created, listed, removed, pruned clean
|
||||
- each of the three safety refusals: an `adopted` lane, a path outside `LANES_ROOT`, a path git does not list as a worktree
|
||||
- `reset` keeps gitignored files and removes untracked ones
|
||||
- the unpushed-commit guard blocks, and `force` overrides it
|
||||
- preflight's counts equal what the action actually changes
|
||||
- `purge` removes sessions, events and token rows, and skips the live session
|
||||
- `add` returns 202 and broadcasts `lane_update` when provisioning finishes
|
||||
|
||||
## Known limitations
|
||||
|
||||
- A fresh worktree has no installed dependencies or local env files (see Non-goals).
|
||||
- Nine worktrees of a large repository cost nine working trees of disk; the `add` preflight estimates the size first.
|
||||
- `--repo` may point anywhere the user can read. That is their own machine; validation is limited to "absolute, exists, is a git repo", and every route stays behind the loopback guard.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Workspace UI rebuild — design
|
||||
|
||||
**Status:** approved 2026-07-29. Sub-project D, built on top of A (merged Workspace page).
|
||||
Reference: the Shipyard "Feature Harness" screen the user supplied.
|
||||
|
||||
## Problem
|
||||
|
||||
The merged Workspace page shipped with the right information and the wrong shape.
|
||||
Everything a lane knows — declared stage, inferred stage, progress, liveness,
|
||||
needs-you — is already on the card (`client/src/components/lanes/LaneCard.tsx`)
|
||||
and already correct on the wire. None of it is legible: the lane strip is a
|
||||
horizontal scroller of cramped cards, the pipeline sits above a console that
|
||||
dominates the viewport, and the `auto: <stage>` chip that proves detection works
|
||||
is 10px of amber text nobody sees.
|
||||
|
||||
Measured on the live install while writing this: lane 5 carried
|
||||
`detected_stage: "tests"` with a real signal, and the user's report was
|
||||
"the lane does not auto-detect". Detection was never broken. The display was.
|
||||
|
||||
Two facts also make lanes look emptier than they are:
|
||||
|
||||
- `branch` and `ci_status` are columns nobody writes, so those rows are always
|
||||
blank even for a managed worktree sitting on a real branch.
|
||||
- Detection is forward-only with no expiry, so a lane parks at the highest stage
|
||||
it ever touched. Lane 5 reached `tests` and can never show `implement` again,
|
||||
even while the agent is editing code.
|
||||
|
||||
## Goal
|
||||
|
||||
The reference screen's legibility, on CCAM's real data: a lane's state readable
|
||||
from across the room, the pipeline large enough to trace, and the console present
|
||||
but out of the way until wanted.
|
||||
|
||||
## Decisions taken
|
||||
|
||||
- **Card grid, not a strip.** Responsive 1 / 2 / 3 columns.
|
||||
- **The console collapses.** It keeps every capability from A; it starts
|
||||
collapsed and opens for the selected lane. Watching lanes is the default
|
||||
posture, driving one is the exception.
|
||||
- **Only real data.** No placeholder tiles for facts CCAM does not have
|
||||
(tickets, preview ports, per-lane credentials). Branch/commit/CI are added
|
||||
because they can be read for real — see below.
|
||||
- **Detection expires.** A detection older than a TTL stops holding the floor.
|
||||
|
||||
## Layout
|
||||
|
||||
Top to bottom, one column:
|
||||
|
||||
```
|
||||
header: title · [N lanes][N running][N need you][N dead] · [+ Add lane]
|
||||
detail: selected lane · declared + inferred headline · large PipelineMap · legend
|
||||
console: collapsed by default; expands to RunSetup + RunConsole + RunHistory
|
||||
grid: lane cards, 1/2/3 columns
|
||||
```
|
||||
|
||||
Selecting a card switches the detail panel and the console together, exactly as
|
||||
A wired it. The console is unchanged behind its new disclosure — no prop of
|
||||
`RunConsole`, `RunSetup` or `RunHistory` moves.
|
||||
|
||||
## The card
|
||||
|
||||
Reference layout, CCAM's fields, nothing invented:
|
||||
|
||||
| Row | Content | Source |
|
||||
|---|---|---|
|
||||
| header | `LANE <id>` · liveness dot · status | `id`, `liveness`, `status` |
|
||||
| title | title, falling back to `cwd` | existing |
|
||||
| progress | declared stage chip · bar · `%` · time on stage | `stage`, `progress`, `stage_seconds` |
|
||||
| inferred | dashed amber `auto: <stage>` with the signal as tooltip | `detected_stage`, `detected_signal` |
|
||||
| tags | `kind` (adopted/managed), CI when known | `kind`, `ci_status` |
|
||||
| git | branch · short head · last commit subject · dirty/untracked counts | new, see below |
|
||||
| alert | needs-you banner | `needs_action` |
|
||||
| actions | start · stop · clear · reset · remove | existing lane actions |
|
||||
|
||||
`reset` and `remove` keep their preflight + `expect` echo through
|
||||
`DestructiveLaneModal`. This redesign does not touch the destroy guard.
|
||||
|
||||
## Git facts
|
||||
|
||||
A new read-only endpoint, `GET /api/lanes/:id/git`, returning
|
||||
`{branch, head, subject, dirty, untracked}` or `{available: false}` when the
|
||||
lane's `cwd` is not a git repo or is unreadable.
|
||||
|
||||
Deliberately **not** folded into `GET /api/lanes`: that payload is polled and
|
||||
broadcast, and shelling out to git once per lane on the hot path would put a
|
||||
subprocess burst behind every hook-driven `lane_update`. The card fetches its
|
||||
own facts when it mounts and on a slow interval, and renders without them until
|
||||
they arrive.
|
||||
|
||||
`server/lib/worktree.js` already has `statusCounts(dir)` returning
|
||||
`{dirty, untracked, head}` and a `git()` wrapper that scrubs the inherited
|
||||
`GIT_*` environment. Both are reused as-is; the endpoint adds only the branch
|
||||
name and the commit subject. No second git helper, no shell strings.
|
||||
|
||||
## Detection expiry
|
||||
|
||||
`recordDetection` gains one rule: a `detected_stage` whose `detected_at` is
|
||||
older than `DETECTION_TTL_MS` (default 30 minutes) no longer blocks a new
|
||||
detection — the forward-only comparison is skipped and the fresh signal wins.
|
||||
Within the window nothing changes: forward-only and declared-wins hold exactly
|
||||
as they do today.
|
||||
|
||||
This keeps the anti-flapping property that motivated forward-only (a `Read`
|
||||
right after an `Edit` must not drag the lane backwards) while admitting the
|
||||
thing it got wrong: a work session ends, and the next one starts somewhere else
|
||||
in the pipeline.
|
||||
|
||||
**Unchanged, and not negotiable:** detection still never writes `lanes.stage`,
|
||||
and an inferred node still never renders `done`.
|
||||
|
||||
## Signal legibility
|
||||
|
||||
`detected_signal` currently captures the whole flattened tool input, so the chip's
|
||||
tooltip reads `cd /very/long/path && npm run test:server 2>&1 | grep …`. The
|
||||
matcher already knows which regex fired; the signal becomes the matched span plus
|
||||
a little context rather than the entire command. Cosmetic, but it is the text the
|
||||
tooltip exists to show.
|
||||
|
||||
## Risks
|
||||
|
||||
- **The console's disclosure is the only structural risk.** Mounting it inside a
|
||||
collapsed container must not unmount `useRunStream` and lose a live stream.
|
||||
The subscription stays mounted; only the visual container collapses.
|
||||
- **Git calls per card.** Bounded by the number of lanes on screen and a slow
|
||||
refresh; failure is silent and the card renders without those rows.
|
||||
- **The screens snapshot over `/run` will change.** It is read, not regenerated
|
||||
blindly.
|
||||
|
||||
## Testing
|
||||
|
||||
- The card renders every field from a fixture lane, and renders without the git
|
||||
block when the endpoint reports `available: false`.
|
||||
- A detected node still never carries `data-state="done"` — the premise guard
|
||||
from sub-project B is re-asserted at the new layout.
|
||||
- Collapsing and expanding the console does not tear down the run subscription:
|
||||
a stream envelope delivered while collapsed is present when it re-expands.
|
||||
- `GET /api/lanes/:id/git` returns the facts for a real repo fixture and
|
||||
`available: false` for a plain directory, and never shells out through a shell.
|
||||
- A detection older than the TTL is accepted even when it is behind the current
|
||||
`detected_stage`; one inside the window is still refused.
|
||||
Reference in New Issue
Block a user