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,408 @@
|
||||
/**
|
||||
* @file brain.ts
|
||||
* @description Pure, framework-free core of the Tabby companion. Reduces the
|
||||
* dashboard's live WebSocket stream into a small mood model and derives the
|
||||
* current cat mood from that model plus the wall clock. Kept side-effect free
|
||||
* so it can be unit-tested without React, timers, or the DOM. The React hook
|
||||
* (`useTabbyBrain`) wires this to the event bus and to real timers.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* 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.
|
||||
*
|
||||
* ## 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
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Mood` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TabbyPulse` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TabbyStatus` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TabbyState` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `HAPPY_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `WORRIED_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `STUCK_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SLEEP_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `FAILURE_EVENT_TYPES` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `initialTabbyState` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `statusOf` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `deriveMood` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `reduceTabby` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `seedSessions` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `clearErrors` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **Mood**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TabbyPulse**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TabbyStatus**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TabbyState**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **HAPPY_MS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **WORRIED_MS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **STUCK_MS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SLEEP_MS**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **FAILURE_EVENT_TYPES**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **initialTabbyState**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **statusOf**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **deriveMood**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **reduceTabby**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **seedSessions**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **clearErrors**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from "../../lib/types";
|
||||
|
||||
/** All moods Tabby can express, highest priority first (see `deriveMood`). */
|
||||
export type Mood =
|
||||
| "disconnected"
|
||||
| "worried"
|
||||
| "stuck"
|
||||
| "happy"
|
||||
| "thinking"
|
||||
| "watching"
|
||||
| "sleeping"
|
||||
| "idle";
|
||||
|
||||
/**
|
||||
* A one-shot signal describing what just happened, emitted by `reduceTabby`.
|
||||
* The hook turns pulses into transient speech bubbles. `null` means the message
|
||||
* was irrelevant or non-notable.
|
||||
*/
|
||||
export type TabbyPulse =
|
||||
| "session_done"
|
||||
| "session_start"
|
||||
| "subagent_spawn"
|
||||
| "waiting"
|
||||
| "error"
|
||||
| "run_done"
|
||||
| null;
|
||||
|
||||
export interface TabbyStatus {
|
||||
/** Active + waiting sessions (everything not finished/errored). */
|
||||
liveCount: number;
|
||||
/** Subset of liveCount currently blocked on user input. */
|
||||
waitingCount: number;
|
||||
errorCount: number;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface TabbyState {
|
||||
connected: boolean;
|
||||
/** Latest status per session id we still care about. "waiting" = active but
|
||||
* blocked on user input; counts as live for the status line. */
|
||||
sessions: Record<string, "active" | "error" | "waiting">;
|
||||
/** Epoch ms of the last meaningful activity; drives stuck/sleeping. */
|
||||
lastActivityAt: number;
|
||||
/** While `now < happyUntil`, mood can be `happy`. */
|
||||
happyUntil: number;
|
||||
/** While `now < worriedUntil`, mood can be `worried`. */
|
||||
worriedUntil: number;
|
||||
/** True while an Ask request is in flight (panel). */
|
||||
thinking: boolean;
|
||||
}
|
||||
|
||||
// Tunable timing constants (ms).
|
||||
export const HAPPY_MS = 4000;
|
||||
export const WORRIED_MS = 4500;
|
||||
export const STUCK_MS = 10 * 60_000;
|
||||
export const SLEEP_MS = 3 * 60_000;
|
||||
|
||||
/** Event types from the hook ingestion that represent a genuine failure. */
|
||||
export const FAILURE_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
"error",
|
||||
"toolError",
|
||||
"agentError",
|
||||
"subagentError",
|
||||
"errorEvent",
|
||||
"errorReport",
|
||||
"errorBoundary",
|
||||
"crashReport",
|
||||
"diagnosticError",
|
||||
]);
|
||||
|
||||
export function initialTabbyState(now: number): TabbyState {
|
||||
return {
|
||||
connected: true,
|
||||
sessions: {},
|
||||
lastActivityAt: now,
|
||||
happyUntil: 0,
|
||||
worriedUntil: 0,
|
||||
thinking: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function statusOf(state: TabbyState): TabbyStatus {
|
||||
let liveCount = 0;
|
||||
let waitingCount = 0;
|
||||
let errorCount = 0;
|
||||
for (const s of Object.values(state.sessions)) {
|
||||
if (s === "active" || s === "waiting") {
|
||||
liveCount++;
|
||||
if (s === "waiting") waitingCount++;
|
||||
} else if (s === "error") errorCount++;
|
||||
}
|
||||
return { liveCount, waitingCount, errorCount, connected: state.connected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure mood resolver. Highest-priority matching state wins. `now` is injected
|
||||
* so callers (and tests) control the clock; transient windows (happy/worried)
|
||||
* and inactivity windows (stuck/sleeping) are evaluated against it.
|
||||
*/
|
||||
export function deriveMood(state: TabbyState, now: number): Mood {
|
||||
if (!state.connected) return "disconnected";
|
||||
if (now < state.worriedUntil) return "worried";
|
||||
|
||||
const { liveCount } = statusOf(state);
|
||||
const silent = now - state.lastActivityAt;
|
||||
|
||||
if (liveCount > 0 && silent > STUCK_MS) return "stuck";
|
||||
if (now < state.happyUntil) return "happy";
|
||||
if (state.thinking) return "thinking";
|
||||
if (liveCount > 0) return "watching";
|
||||
if (silent > SLEEP_MS) return "sleeping";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a single WebSocket message into the Tabby state. Returns the next state
|
||||
* (new object) and a one-shot pulse describing what happened. Unknown or
|
||||
* irrelevant message types pass through unchanged with a `null` pulse.
|
||||
*/
|
||||
export function reduceTabby(
|
||||
state: TabbyState,
|
||||
msg: WSMessage,
|
||||
now: number
|
||||
): { state: TabbyState; pulse: TabbyPulse } {
|
||||
switch (msg.type) {
|
||||
case "session_created":
|
||||
case "session_updated": {
|
||||
const s = msg.data as Session;
|
||||
if (!s || !s.id) return { state, pulse: null };
|
||||
const sessions = { ...state.sessions };
|
||||
let pulse: TabbyPulse = null;
|
||||
let happyUntil = state.happyUntil;
|
||||
let worriedUntil = state.worriedUntil;
|
||||
|
||||
if (s.status === "active") {
|
||||
// "waiting" = active session blocked on user input (permission prompt
|
||||
// or sitting at a fresh prompt). Announce the transition once each way.
|
||||
const isWaiting = !!s.awaiting_input_since;
|
||||
const prev = sessions[s.id];
|
||||
if (isWaiting) {
|
||||
sessions[s.id] = "waiting";
|
||||
if (prev !== "waiting") pulse = "waiting";
|
||||
} else {
|
||||
sessions[s.id] = "active";
|
||||
if (prev === undefined) pulse = "session_start";
|
||||
}
|
||||
} else if (s.status === "error") {
|
||||
sessions[s.id] = "error";
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||
const wasTracked = s.id in sessions;
|
||||
delete sessions[s.id];
|
||||
if (s.status === "completed") {
|
||||
happyUntil = now + HAPPY_MS;
|
||||
if (wasTracked) pulse = "session_done";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
||||
pulse,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_created": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
};
|
||||
}
|
||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||
// is already covered by session_start.
|
||||
const pulse: TabbyPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
||||
return { state: { ...state, lastActivityAt: now }, pulse };
|
||||
}
|
||||
|
||||
case "agent_updated": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
};
|
||||
}
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
|
||||
case "new_event": {
|
||||
const e = msg.data as DashboardEvent;
|
||||
const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
lastActivityAt: now,
|
||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||
},
|
||||
pulse: isFailure ? "error" : null,
|
||||
};
|
||||
}
|
||||
|
||||
case "run_status": {
|
||||
const r = msg.data as RunStatusPayload;
|
||||
if (!r) return { state, pulse: null };
|
||||
// A run that finished cleanly (exit 0, or no exit code reported) → happy.
|
||||
if (r.status === "completed" && (r.exitCode == null || r.exitCode === 0)) {
|
||||
return {
|
||||
state: { ...state, happyUntil: now + HAPPY_MS, lastActivityAt: now },
|
||||
pulse: "run_done",
|
||||
};
|
||||
}
|
||||
// Errored, killed, or completed with a nonzero exit code → worried.
|
||||
if (
|
||||
r.status === "error" ||
|
||||
r.status === "killed" ||
|
||||
(r.status === "completed" && r.exitCode != null && r.exitCode !== 0)
|
||||
) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
};
|
||||
}
|
||||
// spawning / running → activity only.
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
|
||||
case "run_stream":
|
||||
// Streaming output counts as activity but is not itself notable.
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
|
||||
default:
|
||||
return { state, pulse: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate session tracking from a REST snapshot (the same data the dashboard
|
||||
* fetches on load). Without this, the brain only learns about sessions from
|
||||
* live WS deltas that arrive *after* it mounts, so a freshly-loaded page shows
|
||||
* "0 live" even when sessions already exist. Merges in non-finished sessions;
|
||||
* never clears the error window. Live WS deltas continue to refine this.
|
||||
*/
|
||||
export function seedSessions(
|
||||
state: TabbyState,
|
||||
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
||||
now: number
|
||||
): TabbyState {
|
||||
const sessions = { ...state.sessions };
|
||||
for (const r of rows) {
|
||||
if (!r || !r.id) continue;
|
||||
if (r.status === "error") sessions[r.id] = "error";
|
||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
// completed / abandoned: leave untracked.
|
||||
}
|
||||
return { ...state, sessions, lastActivityAt: now };
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
||||
export function clearErrors(state: TabbyState): TabbyState {
|
||||
const sessions: TabbyState["sessions"] = {};
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== "error") sessions[id] = s;
|
||||
}
|
||||
return { ...state, sessions, worriedUntil: 0 };
|
||||
}
|
||||
Reference in New Issue
Block a user