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

Internal SmartGift build of a Claude Code monitoring dashboard.

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

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

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
+851
View File
@@ -0,0 +1,851 @@
/**
* @file event-grouping.ts
* @description Client-side helpers for rendering a flat stream of
* `DashboardEvent` rows: a per-event status tag (`statusFromEventType`), a
* smart human-readable title (`buildEventTitle`), and agent/origin labels for
* the muted "{project} › {session} › {agent}" prefix. (The historical
* tool-call grouping view was removed; the timeline now renders flat only.)
*
* ## Event shape
* Every helper here operates on a {@link DashboardEvent}. The fields that
* matter for titling and attribution are:
* - `event_type` — the hook lifecycle name ("PreToolUse", "PostToolUse",
* "Stop", "SubagentStop", "Compaction", "Notification", "SessionStart",
* "SessionEnd", "TurnDuration", "APIError", …). Drives
* {@link statusFromEventType}.
* - `tool_name` — set only on tool events (e.g. "Bash", "Edit", "Read", or an
* MCP name like "mcp__github__create_issue"). Absent for lifecycle events, in
* which case {@link buildEventTitle} falls back to `summary`.
* - `summary` — an optional server-provided one-liner used as a fallback.
* - `data` — a JSON *string* holding the raw hook payload. When parsed it
* typically exposes `tool_input` (the arguments passed to the tool) and `cwd`
* (the working directory, used to derive the project label).
* - `agent_id` — identifies which agent (main or subagent) emitted the event;
* drives the {@link shortAgentLabel} / {@link agentOriginLabel} labels.
*
* ## Design philosophy
* Titles are produced *algorithmically* — there is deliberately no per-tool or
* per-MCP-server lookup table to maintain. New tools and MCP servers therefore
* render sensibly on day one: MCP names are decoded from their namespaced
* `mcp__<server>__<tool>` structure, and unknown native tools fall back to the
* first short string found in their payload. Every parser is defensive — bad
* JSON, missing fields, and unexpected types degrade to a plain label instead of
* throwing, because this code runs on live hook data of varying vintage.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## 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
* - `./types`
*
* ## Public surface
* - `statusFromEventType` — exported API; see TSDoc on the symbol for behavior.
* - `buildEventTitle` — exported API; see TSDoc on the symbol for behavior.
* - `shortAgentLabel` — exported API; see TSDoc on the symbol for behavior.
* - `AgentInfo` — exported API; see TSDoc on the symbol for behavior.
* - `agentPillLabel` — exported API; see TSDoc on the symbol for behavior.
* - `agentOriginLabel` — exported API; see TSDoc on the symbol for behavior.
* - `buildOriginLabel` — exported API; see TSDoc on the symbol for behavior.
* - `projectFromCwd` — exported API; see TSDoc on the symbol for behavior.
* - `projectFromEvent` — 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).
* -----------------------------------------------------------------------------
* **statusFromEventType**
* 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.
*
* **buildEventTitle**
* 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.
*
* **shortAgentLabel**
* 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.
*
* **AgentInfo**
* 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.
*
* **agentPillLabel**
* 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.
*
* **agentOriginLabel**
* 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.
*
* **buildOriginLabel**
* 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.
*
* **projectFromCwd**
* 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.
*
* **projectFromEvent**
* 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 { DashboardEvent } from "./types";
// ════════════════════════════════════════════════════════════════════════════
// Status mapping
// ════════════════════════════════════════════════════════════════════════════
/** Best-effort status tag per event_type - drives the status badge shown on
* each row in the ActivityFeed / SessionDetail event streams.
* @param type A `DashboardEvent.event_type` value (e.g. "PreToolUse", "Stop").
* @returns The badge status; unrecognized types default to "waiting" rather
* than throwing, since new hook event types should degrade gracefully. */
export function statusFromEventType(type: string): "working" | "waiting" | "completed" | "error" {
switch (type) {
// A tool is about to run: the agent is actively doing work.
case "PreToolUse":
return "working";
// The tool finished, or the turn stopped: the agent is idle / awaiting the
// next step. "waiting" (not "completed") because more activity usually
// follows a PostToolUse within the same turn.
case "PostToolUse":
case "Stop":
return "waiting";
// Terminal-ish milestones: a subagent handed control back, or the transcript
// was compacted. Both read as a finished unit of work.
case "SubagentStop":
case "Compaction":
return "completed";
// Explicit failure signals surface as the red "error" badge.
case "error":
case "APIError":
return "error";
// Unknown / newer event types shouldn't blow up the badge — treat them as a
// neutral "waiting" so future hook additions render gracefully.
default:
return "waiting";
}
}
// ════════════════════════════════════════════════════════════════════════════
// Dynamic humanizers (no per-tool static tables)
//
// These small string helpers turn machine-shaped identifiers — MCP server slugs,
// snake_case tool names, shell commands, file paths, URLs — into short
// human-readable fragments. None of them carry a hard-coded catalogue of known
// tools; they rely purely on the *structure* of the input, so a brand-new MCP
// server or CLI renders acceptably without a code change.
// ════════════════════════════════════════════════════════════════════════════
/** Purely algorithmic: split on _/-, dedupe consecutive tokens, take last,
* capitalize-first if all lowercase. Handles any MCP server slug.
*
* The goal is a compact, recognizable server name. The *last* meaningful token
* is usually the brand (e.g. "claude_ai_Slack" → "Slack"), and consecutive
* duplicate tokens (from slugs like "github_github") are collapsed so they
* don't read twice. Capitalization is only forced when the token is entirely
* lowercase, preserving already-cased brands like "GitLab" or "PagerDuty".
* @param raw The raw server slug (the `<server>` piece of an MCP tool name).
* @returns A short, display-ready server label.
* @example humanizeMcpServer("claude_ai_Slack") // "Slack"
* @example humanizeMcpServer("github") // "Github"
* @example humanizeMcpServer("atlassian") // "Atlassian"
*/
function humanizeMcpServer(raw: string): string {
// Split on underscores/hyphens into candidate tokens, dropping empties.
const tokens = raw.split(/[_-]+/).filter(Boolean);
// Collapse *consecutive* duplicate tokens ("github_github" → ["github"]).
const dedup: string[] = [];
for (const t of tokens) {
if (dedup[dedup.length - 1] !== t) dedup.push(t);
}
// The trailing token is the most brand-identifying part; fall back to the
// untouched input if the slug somehow had no usable tokens.
const last = dedup[dedup.length - 1] ?? raw;
// Only capitalize purely-lowercase tokens so existing mixed-case brands
// (GitLab, PagerDuty) are left untouched.
return last.toLowerCase() === last ? last.charAt(0).toUpperCase() + last.slice(1) : last;
}
/** snake_case → lowercase words with spaces (e.g. "get_merge_request" → "get merge request").
*
* Runs of underscores collapse to a single space, surrounding whitespace is
* trimmed, and the result is lowercased so tool actions read as a short verb
* phrase in the title (e.g. "Github · create pull request").
* @param raw The `<tool>` portion of an MCP tool name (may contain several
* `_`-joined words).
* @returns The spaced, lowercased action phrase.
*/
function humanizeMcpTool(raw: string): string {
return raw.replace(/_+/g, " ").trim().toLowerCase();
}
/** Splits an `mcp__<server>__<tool...>` tool name into its humanized server
* and tool parts. Returns null for anything that isn't a well-formed MCP
* tool name (no `mcp__` prefix, or fewer than 3 `__`-separated segments).
*
* MCP tool names follow the convention `mcp__<server>__<tool>` where `<tool>`
* may itself contain `__` if the underlying action name had underscores. The
* first segment ("mcp") is the marker, the second is the server slug, and
* everything after is re-joined as the action before being humanized.
* @param tool A raw `tool_name`, e.g. "mcp__github__create_pull_request".
* @returns `{ server, tool }` with both pieces humanized, or null when `tool` is
* not a namespaced MCP name.
* @example
* parseMcpToolName("mcp__github__create_pull_request")
* // → { server: "Github", tool: "create pull request" }
* parseMcpToolName("Bash") // → null (native tool, no mcp__ prefix)
*/
function parseMcpToolName(tool: string): { server: string; tool: string } | null {
if (!tool.startsWith("mcp__")) return null;
// `filter(Boolean)` drops the empty strings produced by the doubled
// underscores, leaving ["mcp", <server>, ...<toolWords>].
const parts = tool.split("__").filter(Boolean);
if (parts.length < 3) return null;
const rawServer = parts[1];
const rest = parts.slice(2);
// Guard against malformed names like "mcp____foo" that survive the length
// check but leave no server or no tool segment.
if (!rawServer || rest.length === 0) return null;
return {
server: humanizeMcpServer(rawServer),
// Re-join with "_" so humanizeMcpTool can re-split uniformly, restoring the
// original multi-word action (parts were split on "__", not "_").
tool: humanizeMcpTool(rest.join("_")),
};
}
/** First short string found in tool_input using a generic priority list, then
* falling back to any other short string. Applies to both MCP and native
* tools - no tool-specific knowledge baked in.
*
* Order matters: fields are tried top-to-bottom and the first non-empty string
* wins, so the list is sorted from *most* human-meaningful ("description",
* "title") down to more incidental identifiers ("id", "command"). This lets a
* single lookup produce a good headline across wildly different tool payloads
* without knowing which tool produced them. */
const CONTEXT_FIELDS = [
"description", // human-authored summary — best possible headline
"title", // e.g. an issue / PR title
"name", // a resource or entity name
"query", // search-style tools
"q", // short alias some tools use for a query
"pattern", // grep / glob style matchers
"url", // web-oriented tools
"file_path", // file-oriented tools (absolute or relative)
"path", // directory / file path variant
"id", // last-resort identifier
"command", // shell command text
];
/** Implements the {@link CONTEXT_FIELDS} lookup described above: returns the
* first matching field's string value, or (failing that) the first short
* (<120 char) string value found anywhere in `input`. Null if none qualify.
* @param input A parsed `tool_input` object (arbitrary tool arguments).
* @returns The chosen headline string, or null when nothing suitable is found.
* @example buildContextHeadline({ query: "auth bug", limit: 20 }) // "auth bug"
*/
function buildContextHeadline(input: Record<string, unknown>): string | null {
// Preferred pass: honor the priority order in CONTEXT_FIELDS. Any non-empty
// string wins here regardless of length — a named field is intentional.
for (const field of CONTEXT_FIELDS) {
const v = input[field];
if (typeof v === "string" && v.length > 0) return v;
}
// Fallback pass: no known field matched, so scan every value and accept the
// first *short* string. The <120 guard avoids surfacing a giant blob (e.g. a
// file's contents) as the headline.
for (const v of Object.values(input)) {
if (typeof v === "string" && v.length > 0 && v.length < 120) return v;
}
return null;
}
// ════════════════════════════════════════════════════════════════════════════
// Shell command parsing
// ════════════════════════════════════════════════════════════════════════════
/** Parses a Bash/PowerShell command string into "<binary> <subcommand>" when
* the binary is something with common subcommands (git, npm, docker, etc.).
* For curl/wget we surface the host. Falls back to the bare binary name.
*
* The set below is the allow-list of binaries whose *first argument* is a
* meaningful subcommand worth showing ("git commit", "npm install", "docker
* build"). For anything not in the set, showing a lone argument would be noise,
* so {@link parseShellHeadline} keeps just the binary name. */
const SUBCOMMAND_BINARIES = new Set([
"git",
"npm",
"pnpm",
"yarn",
"bun",
"docker",
"docker-compose",
"just",
"make",
"cargo",
"python",
"pip",
"poetry",
"uv",
"node",
"npx",
"kubectl",
"terraform",
"helm",
"aws",
"gcloud",
"az",
]);
/** Extracts a compact headline from a raw shell command string.
* @param command The full command line (e.g. "git commit -m 'wip' && npm test").
* @returns "<binary> <subcommand>" for known multi-command binaries, "<curl|wget>
* <host>" for downloads, the bare binary otherwise, or null for an empty
* command.
* @example parseShellHeadline("git commit -m x") // "git commit"
* @example parseShellHeadline("docker compose up -d") // "docker compose up"
* @example parseShellHeadline("curl https://api.x/y") // "curl api.x"
* @example parseShellHeadline("./run.sh --fast") // "run.sh"
*/
function parseShellHeadline(command: string): string | null {
const cmd = command.trim();
if (!cmd) return null;
// Special case: "docker compose <sub>" (two-word binary)
const compose = cmd.match(/^docker\s+compose\s+([A-Za-z0-9_-]+)/);
if (compose) return `docker compose ${compose[1]}`;
// Capture group 1 = the binary (path chars allowed so "./x", "/usr/bin/git"
// and "C:\\tool.exe" all match); optional group 2 = the first bare argument.
const match = cmd.match(/^([A-Za-z0-9_.\-/\\]+)(?:\s+([A-Za-z0-9_-]+))?/);
if (!match) return null;
const binPath = match[1] ?? "";
// Reduce any path to just the executable name so "/usr/local/bin/git" → "git".
const bin = binPath.split(/[/\\]/).pop() || binPath;
const sub = match[2];
// Only show the subcommand for binaries where it's genuinely informative.
if (SUBCOMMAND_BINARIES.has(bin) && sub) return `${bin} ${sub}`;
// Downloads: the destination host is far more useful than a "-fsSL" flag, so
// pull the first http(s) URL out of the command and show its host.
if (bin === "curl" || bin === "wget") {
const urlMatch = cmd.match(/https?:\/\/[^\s"']+/);
if (urlMatch) {
try {
return `${bin} ${new URL(urlMatch[0]).host}`;
} catch {
/* ignore */
}
}
return bin;
}
// Everything else: just the binary name (a lone arg would usually be noise).
return bin;
}
// ════════════════════════════════════════════════════════════════════════════
// Path and URL helpers
// ════════════════════════════════════════════════════════════════════════════
/** Last path segment (POSIX or Windows separators). Returns `path` unchanged
* if it has no separators.
* @param path An absolute or relative path using "/" and/or "\" separators.
* @returns The final segment (file or directory name).
* @example basename("/a/b/c.ts") // "c.ts"
* @example basename("solo") // "solo"
*/
function basename(path: string): string {
// Split on either separator and drop empties so trailing slashes don't yield
// an empty final element.
const parts = path.split(/[/\\]/).filter(Boolean);
return parts.length > 0 ? (parts[parts.length - 1] ?? path) : path;
}
/** Compact path label - last 2 segments (e.g. "tasks/base.py" for a long
* absolute path ending in tasks/base.py), so the user sees the immediate
* parent directory in addition to the filename. Falls back to basename for
* single-segment paths.
*
* Two segments strike a balance: the filename alone can be ambiguous (many
* "index.ts"), while the full absolute path is too long for a one-line title.
* @param path An absolute or relative path.
* @returns The last two segments joined with "/", or the sole segment.
* @example shortPath("/repo/client/src/lib/types.ts") // "lib/types.ts"
* @example shortPath("README.md") // "README.md"
*/
function shortPath(path: string): string {
const parts = path.split(/[/\\]/).filter(Boolean);
// 0 or 1 segments: nothing to shorten — return what we have.
if (parts.length <= 1) return parts[0] ?? path;
// Always normalize the joiner to "/" even for Windows-style inputs.
return parts.slice(-2).join("/");
}
/** Extracts the host from a URL string (e.g. WebFetch's target), falling back
* to the raw string if it doesn't parse as a URL.
* @param url A URL string; may be malformed.
* @returns The host (e.g. "api.github.com"), or the original string on parse
* failure so the caller still shows *something*.
* @example hostFromUrl("https://api.github.com/repos") // "api.github.com"
* @example hostFromUrl("not a url") // "not a url"
*/
function hostFromUrl(url: string): string {
try {
return new URL(url).host;
} catch {
// `new URL` throws on relative / garbage input — degrade to the raw string.
return url;
}
}
// ════════════════════════════════════════════════════════════════════════════
// Event title builder
// ════════════════════════════════════════════════════════════════════════════
/** Parses `event.data` and pulls out its `tool_input` object, if any. Returns
* null when there's no data, it isn't valid JSON, or `tool_input` isn't a
* plain object (e.g. absent, or an array).
*
* `event.data` is stored as a JSON string, so it must be parsed at read time.
* A representative payload looks like:
* `{"tool_input":{"file_path":"/a/b.ts"},"cwd":"/a"}`.
* @param event The event whose payload should be inspected.
* @returns The `tool_input` record, or null when it's missing / invalid.
*/
function extractToolInput(event: DashboardEvent): Record<string, unknown> | null {
if (!event.data) return null;
try {
const parsed = JSON.parse(event.data);
// Only read `tool_input` when the payload itself is a truthy object.
const maybeInput = parsed && typeof parsed === "object" ? parsed.tool_input : null;
// Require a *plain* object — arrays and primitives aren't valid inputs and
// would break the field lookups downstream.
if (maybeInput && typeof maybeInput === "object" && !Array.isArray(maybeInput)) {
return maybeInput as Record<string, unknown>;
}
} catch {
/* ignore — malformed JSON simply yields no input */
}
return null;
}
/** Returns a short, descriptive title for an event. Parses `tool_input` and
* dispatches per-tool to surface what actually happened (e.g. "Bash · git
* commit", "GitLab · get merge request · !174", "Edit SessionDetail.tsx"),
* instead of the generic "Using tool: X" summary. MCP tools are rendered
* dynamically from their namespaced name - no per-server static mapping.
* @param event The event to title. Non-tool events fall back to `summary`
* (or `event_type` if there's no summary either).
* @returns A one-line title, never empty. */
export function buildEventTitle(event: DashboardEvent): string {
// Lifecycle (non-tool) events have no tool_name — use the server summary, or
// the raw event_type as a last resort. Never returns empty.
if (!event.tool_name) return event.summary || event.event_type;
const input = extractToolInput(event);
// Local coercion helper: read a field as a string, or "" if it's absent or a
// non-string. Keeps the per-tool branches below terse.
const s = (v: unknown): string => (typeof v === "string" ? v : "");
// Local truncator: clamp long values (commands, descriptions) so a single
// title never blows out the row. Default cap is 80 chars.
const trunc = (text: string, max = 80): string =>
text.length > max ? text.slice(0, max) + "..." : text;
// ── MCP tools - fully dynamic dispatch ─────────────────────────────
// Any "mcp__server__tool" name is decoded structurally (no per-server table).
// When the payload yields a context headline we append it: "Github · create
// pull request · Fix flaky test".
const mcp = parseMcpToolName(event.tool_name);
if (mcp) {
const ctx = input ? buildContextHeadline(input) : null;
return ctx ? `${mcp.server} · ${mcp.tool} · ${trunc(ctx)}` : `${mcp.server} · ${mcp.tool}`;
}
// No parseable input (missing / invalid data): fall back to the tool name plus
// any server summary. The native per-tool logic below all needs `input`.
if (!input) return `${event.tool_name}${event.summary ? `: ${event.summary}` : ""}`;
// ── Native tools - per-tool smart titles ───────────────────────────
// Each case surfaces the single most useful fact from that tool's arguments.
// A `break` (rather than return) falls through to the generic tail at the
// bottom, used when the expected field was absent.
switch (event.tool_name) {
case "Bash":
case "PowerShell": {
// Prefer "<tool> · <bin sub> - <description>"; degrade gracefully as
// fields drop out (headline only, description only, then raw command).
const desc = s(input.description);
const cmd = s(input.command);
const headline = parseShellHeadline(cmd);
if (headline && desc) return `${event.tool_name} · ${headline} - ${trunc(desc, 60)}`;
if (headline) return `${event.tool_name} · ${headline}`;
if (desc) return `${event.tool_name}: ${desc}`;
if (cmd) return `${event.tool_name}: ${trunc(cmd)}`;
break;
}
case "Read": {
// Show the compact two-segment path so the file is identifiable.
const path = s(input.file_path);
if (path) return `Read · ${shortPath(path)}`;
break;
}
case "Write": {
// Same treatment as Read — the destination path is the key fact.
const path = s(input.file_path);
if (path) return `Write · ${shortPath(path)}`;
break;
}
case "Edit":
case "NotebookEdit": {
const path = s(input.file_path);
if (path) {
// Flag global replacements so a sweeping edit is visually distinct.
const suffix = input.replace_all === true ? " (all)" : "";
return `${event.tool_name} · ${shortPath(path)}${suffix}`;
}
break;
}
case "Grep": {
// Lead with the search pattern; append the scope directory when present.
const pattern = s(input.pattern);
const path = s(input.path);
if (pattern) {
return path
? `Grep · "${trunc(pattern, 40)}" in ${basename(path)}`
: `Grep · "${trunc(pattern, 40)}"`;
}
break;
}
case "Glob": {
// The glob pattern *is* the action; show it verbatim (already short).
const pattern = s(input.pattern);
if (pattern) return `Glob · "${pattern}"`;
break;
}
case "WebFetch": {
// Only the host is meaningful at a glance; the full URL is often huge.
const url = s(input.url);
if (url) return `WebFetch · ${hostFromUrl(url)}`;
break;
}
case "Agent":
case "Task": {
// Subagent spawns: identify the agent kind and/or its task description,
// e.g. "Task · frontend-reviewer - audit the new modal".
const desc = s(input.description);
const subtype = s(input.subagent_type);
if (desc && subtype) return `${event.tool_name} · ${subtype} - ${trunc(desc, 60)}`;
if (desc) return `${event.tool_name} · ${trunc(desc, 60)}`;
if (subtype) return `${event.tool_name} · ${subtype}`;
break;
}
// Task-management tools all share the same shape: prefer a human
// description, else the task id.
case "TaskCreate":
case "TaskUpdate":
case "TaskGet":
case "TaskStop":
case "TaskOutput":
case "TaskList": {
const desc = s(input.description);
const id = s(input.id);
if (desc) return `${event.tool_name} · ${trunc(desc, 60)}`;
if (id) return `${event.tool_name} · ${id}`;
break;
}
case "ScheduleWakeup": {
// Show the delay in seconds and, when given, the reason for the wakeup.
const delay = input.delaySeconds;
const reason = s(input.reason);
if (typeof delay === "number") {
return `ScheduleWakeup · ${delay}s${reason ? ` - ${trunc(reason, 50)}` : ""}`;
}
break;
}
case "AskUserQuestion": {
// `questions` is an array of objects; surface the first question's text.
const qs = input.questions;
if (Array.isArray(qs) && qs.length > 0) {
const first = qs[0];
if (first && typeof first === "object") {
const q = s((first as Record<string, unknown>).question);
if (q) return `AskUserQuestion · "${trunc(q, 60)}"`;
}
}
break;
}
case "Monitor": {
// Monitor watches a shell command; show the (truncated) command text.
const cmd = s(input.command);
if (cmd) return `Monitor · ${trunc(cmd)}`;
break;
}
case "ToolSearch": {
// The search query is the action being performed.
const q = s(input.query);
if (q) return `ToolSearch · ${trunc(q, 60)}`;
break;
}
default: {
// Unknown native tool: reuse the generic CONTEXT_FIELDS headline so even
// never-before-seen tools get a meaningful title instead of "Using tool".
const ctx = buildContextHeadline(input);
if (ctx) return `${event.tool_name} · ${trunc(ctx)}`;
}
}
// Tail fallback: reached when a matched case `break`s because its expected
// field was missing. Show the tool name plus any server summary.
return `${event.tool_name}${event.summary ? ` · ${event.summary}` : ""}`;
}
// ════════════════════════════════════════════════════════════════════════════
// Agent attribution labels
//
// These helpers turn an `agent_id` (and optional AgentInfo) into the short
// labels shown next to events. A session has one "main" agent plus zero or more
// nested subagents; the goal is to identify *which* agent acted without adding
// noise for the common main-agent case.
// ════════════════════════════════════════════════════════════════════════════
/** Returns a short agent label for display next to an event, or null when the
* event belongs to the session's main agent (no disambiguation needed).
* @param agentId The event's `agent_id`, or null.
* @returns The last-8 of the id for subagents, the whole id when short, or null
* for the main agent / a missing id.
*/
export function shortAgentLabel(agentId: string | null): string | null {
if (!agentId) return null;
// Main-agent ids end in "-main"; those need no pill (they're the default).
if (agentId.endsWith("-main")) return null;
// Last 8 chars of the UUID is enough to distinguish subagents on the same row.
return agentId.length > 8 ? agentId.slice(-8) : agentId;
}
/** Minimal subset of an Agent record, enough to render a subagent pill and
* walk the parent chain (so events from a nested subagent can render the
* full "main › coder › explorer" attribution). */
export type AgentInfo = {
/** "main" for the session's root agent, "subagent" for any spawned agent. */
type: "main" | "subagent";
/** The subagent's kind (e.g. "frontend-reviewer"); null for main agents. */
subagent_type: string | null;
/** A human name / label for the agent; used when `subagent_type` is empty. */
name: string;
/** Parent agent's id, enabling the chain walk in {@link agentOriginLabel}. */
parent_agent_id?: string | null;
};
/** Single-segment label for an agent - the pill text. Returns null when the
* agent is the session's main agent (pill is noise in that case).
*
* Preference order: the descriptive `subagent_type` (most meaningful), then the
* agent's `name`, then null when neither is populated.
* @param info The agent record to label.
* @returns One label segment, or null for main / unlabeled agents.
*/
function singleAgentSegment(info: AgentInfo): string | null {
if (info.type === "main") return null;
if (info.subagent_type && info.subagent_type.length > 0) return info.subagent_type;
if (info.name && info.name.length > 0) return info.name;
return null;
}
/** Resolves the pill label for an event's agent. Returns null when the event
* comes from the session's main agent (the pill is noise in that case) or
* when no info is available. Prefers subagent_type (e.g. "frontend-reviewer"),
* then the agent's name, and finally the last-8 short ID fallback.
* @param agentId The event's `agent_id`; null yields null.
* @param info Optional {@link AgentInfo} for that agent, when known.
* @returns The pill text, or null when nothing worth showing exists.
*/
export function agentPillLabel(agentId: string | null, info: AgentInfo | undefined): string | null {
if (!agentId) return null;
if (info) {
const seg = singleAgentSegment(info);
// A concrete subagent label wins outright.
if (seg !== null) return seg;
// Known main agent with no segment → deliberately no pill.
if (info.type === "main") return null;
}
// No usable info: fall back to the id-derived short label.
return shortAgentLabel(agentId);
}
/** Resolves a label that always identifies an event's agent origin - unlike
* agentPillLabel, this returns "main" for main agents instead of null. Used
* by the inline origin prefix ("{session} › {agent} · {action}").
*
* When an `agentInfoById` map is provided AND the event's agent has a
* parent_agent_id, the chain is walked from the root subagent down to the
* current agent and joined with " › " - so an event triggered by a deeply
* nested subagent reads "main › coder › explorer" instead of just "explorer".
* Cycles and missing parents fall back gracefully to the single-segment label.
* @param agentId The event's `agent_id`; null yields null (no origin to show).
* @param infoOrMap Either one agent's {@link AgentInfo} (legacy single-segment
* behavior) or a `Map<agentId, AgentInfo>` covering the session (enables the
* parent-chain walk).
* @returns "main", a single subagent segment, a "main › a › b" chain, or the
* {@link shortAgentLabel} fallback when no info is available.
* @example
* agentOriginLabel("sub-42", agentInfoById) // "main › coder › explorer"
*/
export function agentOriginLabel(
agentId: string | null,
infoOrMap: AgentInfo | Map<string, AgentInfo> | undefined
): string | null {
if (!agentId) return null;
// Overload detection: a Map enables the parent-chain walk; a bare AgentInfo
// (or undefined) keeps the legacy single-segment path.
const map = infoOrMap instanceof Map ? infoOrMap : null;
const info = map ? map.get(agentId) : (infoOrMap as AgentInfo | undefined);
// No map - preserve the legacy single-segment behavior for callers that
// haven't switched to the chain-aware overload yet.
if (!map) {
if (info) {
if (info.type === "main") return "main";
const seg = singleAgentSegment(info);
if (seg) return seg;
}
// Without info, infer "main" from the id suffix, else use the short id.
if (agentId.endsWith("-main")) return "main";
return shortAgentLabel(agentId);
}
// Map provided - walk parent chain so nested subagents read "main › coder".
const segments: string[] = [];
// `seen` guards against a corrupt parent cycle causing an infinite loop.
const seen = new Set<string>();
let cursor: string | null = agentId;
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
const node = map.get(cursor);
// Missing node: the chain is broken — stop and use whatever we gathered.
if (!node) break;
if (node.type === "main") {
// Reached the root; prepend "main" and stop climbing.
segments.unshift("main");
break;
}
// `unshift` builds the chain root-first so it reads top-down.
const seg = singleAgentSegment(node);
if (seg) segments.unshift(seg);
cursor = node.parent_agent_id ?? null;
}
// Walk produced nothing usable (e.g. id absent from the map): fall back the
// same way the map-less branch does.
if (segments.length === 0) {
if (agentId.endsWith("-main")) return "main";
return shortAgentLabel(agentId);
}
return segments.join(" › ");
}
// ════════════════════════════════════════════════════════════════════════════
// Origin prefix and project derivation
// ════════════════════════════════════════════════════════════════════════════
/** Builds the muted origin prefix shown before a row's action title, e.g.
* "datapilot › DataPilot › frontend-reviewer". Returns null when nothing
* identifying is available. Any of the three segments may be null - pages
* already scoped to a single session pass null for sessionName, etc. When a
* segment equals the previous one (e.g. project name == session name), it
* is dropped to avoid visual duplication.
* @param projectName Leading segment (usually the working-directory name).
* @param sessionName Middle segment; dropped when identical to projectName.
* @param agentLabel Trailing segment (from {@link agentOriginLabel}).
* @returns The " › "-joined prefix, or null when every segment was empty.
*/
export function buildOriginLabel(
projectName: string | null | undefined,
sessionName: string | null | undefined,
agentLabel: string | null
): string | null {
const parts: string[] = [];
if (projectName) parts.push(projectName);
// Skip the session name when it just repeats the project name.
if (sessionName && sessionName !== projectName) parts.push(sessionName);
if (agentLabel) parts.push(agentLabel);
return parts.length > 0 ? parts.join(" › ") : null;
}
/** Last path segment of a working directory - the project/dir name shown as the
* leading origin segment. Null for an empty or missing cwd. Use this to derive
* a fallback project for events whose own payload carries no `cwd` (e.g.
* TurnDuration), by passing the owning session's cwd.
* @param cwd An absolute working-directory path, or null/undefined.
* @returns The final path segment, or null when there's no usable cwd.
* @example projectFromCwd("/Users/me/dev/datapilot") // "datapilot"
*/
export function projectFromCwd(cwd: string | null | undefined): string | null {
if (typeof cwd !== "string" || cwd.length === 0) return null;
return basename(cwd);
}
/** Reads `cwd` out of an event's payload and returns the last path segment
* (the project/directory name). Null when the payload doesn't include cwd
* (e.g. TurnDuration events, or events from a very old client) - callers can
* fall back to `projectFromCwd(session.cwd)` in that case.
* @param event The event whose JSON `data` payload may carry a `cwd`.
* @returns The derived project name, or null when no `cwd` is present / valid.
*/
export function projectFromEvent(event: DashboardEvent): string | null {
if (!event.data) return null;
try {
const parsed = JSON.parse(event.data);
// Only a plain-object payload can carry a top-level `cwd` string.
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const cwd = (parsed as Record<string, unknown>).cwd;
if (typeof cwd === "string" && cwd.length > 0) return projectFromCwd(cwd);
}
} catch {
/* ignore — no parseable cwd means the caller uses its session fallback */
}
return null;
}