SLOPSHOPPER

q-graph

Shows the subagents and teammates of a session as a live graph: a roster band above the prompt, an alert when one waits for you, and a pane with the agent…

panebandguardcommandtoastprompt
★ 1v0.1.1MITupdated 2026-10-16phuquocchamp/q-harness/plugins/q-graph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · q-graph
│ ┃ q-graph ✕ › fix the failing auth test and add an audit log call │ ┃ 0 agents · 0 running · 0 need you · 0 tok [ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ harness configured · loaded │ ⏺ Update(src/auth.ts) │ ┃ │ rules 0 │ ⎿ Added 2 lines, removed 1 line │ ┃ │ hooks 0 │ ⏺ Bash(bun test) │ ┃ │ skills 0 │ ⎿ 3 pass, 1 fail │ ┃ │ mcp 0 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /q-graph │ ┃ ⎿ q-graph: q-graph pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · q-graph
0 agents · 0 running · 0 need you · 0 tok [ ↗ open in brows ╭──────────────────────────────────────────╮ │ harness configured · loaded │ │ rules 0 │ │ hooks 0 │ │ skills 0 │ │ mcp 0
README

<img src="../../docs/images/icons/q-graph.svg" width="72" alt="" align="right">

q-graph

Part of q-harness, a collection of Claude Code plugins by phuquocchamp

Which agents are running, which one is stuck, and which one is waiting for me? q-graph records every agent loop of the session (the main loop, subagents and teammates), shows the live ones in a band above the prompt, warns you when one waits for an answer, and draws the whole tree in a pane with a dot moving along the edge of each running agent.

Version 0.1.1 · Author phuquocchamp · Design and roadmap (artifact)

What it ships

FeatureWhat you seeUse it when
Roster bandA row above the prompt with one entry per live agent: Explore ◐ Grep, Plan ⚠ needs you 42s, general-purpose ⏸ waiting, backend@auth ○ idle, then a graph button. When they do not fit on one row, the rest fold into a +N more button: press it to list every agent on wrapped rows, and show less to fold them again. Finished agents stay 30 seconds. An agent that vanished from the engine's list and has been silent for 45 seconds shows ? lost; any later hook event for it brings it back. The band hides when there are none.Claude fans work out to subagents or a team and you want to see who is doing what.
Needs-you alertA toast, and a native notification, when a subagent or teammate asks a question (AskUserQuestion) or waits for plan approval (ExitPlanMode), and when one fails. It stays raised while any such call is in flight, even when the agent runs other tools in parallel (calls are tracked by their tool-use id). An agent first seen already failed raises nothing, and an alert never delays the tool call. An agent the engine marks waiting on its own background work or on a child agent shows ⏸ waiting and raises nothing.You are in another window while agents run.
Graph pane/q-graph opens a pane with the agent tree, left to right: main → subagents → their subagents. Each card is a rounded frame in the agent's colour (bright while it runs, quiet once it is done) with its name, model, time, status (or the tool it is running) and tokens. Edges are box-drawing lines at right angles: one trunk per parent and a ├──▸ branch to each child; a running child's branch takes its colour and a ● moves along it. Dashed ┄ loops join agents that messaged each other, in their own columns right of the cards. A card that needs you says ! needs you. When the tree is wider than the pane, the columns move closer, then the cards narrow, and levels that still do not fit are counted under the graph with a show all in browser button.You want the whole picture of a session, or to find the agent that is stuck.
Uses per agentUnder each card, including main's, hangs the list of what that agent used, each with its own ├▸ branch: tools (Bash ×17), skills (skill commit), MCP servers (mcp context7 ×2) and the rules or CLAUDE.md files it loaded (rule conventions.md), each kind in its own colour. Two agents that both run Bash each get their own Bash row with their own count. A use fills while the agent runs it and for about a second after. Every use is listed, in the order the agent first used it; none is folded away, in the pane or in the browser viewer. A call that starts an agent is not listed: the child's card is that call.You want to see what each agent actually did, and with which part of the harness.
Harness cardOn top of the graph, the session's harness: settings hooks per event, the memory files the context carries (CLAUDE.md, rules), skills, MCP servers and custom agents. Read at session start and again at each prompt (MCP servers connect late). /q-graph status prints it as one line.You want to know what is configured and loaded before you read what the agents did.
Live browser viewer/q-graph web (or /q-graph open --web) starts a small local viewer and prints its link. The pane header always has an ↗ open in browser button: it starts the viewer if needed and opens it in your default browser (open on macOS, xdg-open on Linux, start on Windows); while the viewer runs, its link is shown next to the button too. The page is an interactive React Flow canvas of the same graph, uses and harness card included: drag nodes (a card's uses move with it), the ⟲ button in the zoom controls puts every moved card back in the tree, zoom, a minimap (hidden below 760 px), moving dots on running edges and on the edge of a use while it runs. It opens top-left at a readable zoom (0.9 to 1) instead of fitting the whole tree. Each card shows the first line of its task under the name, — tok until the agent's first turn ends, and waiting on N agents when an idle agent has running children. It updates live while the session runs. An **AllWorking** switch next to GraphTimeline (remembered in this browser) hides the agents that are done, idle, stopped or lost, with their uses, and keeps main, every agent still running, starting, waiting or needing you, and the agents above them so the tree stays connected; it shows how many it hides (Working · 12 hidden). The detail sidebar on the right is pinned open by default and shows main until you click another node (see below).You want to explore a big session with the mouse, keep the graph on a second screen, or see only what is still working once a large team has finished.

In the terminal the graph is a Raster: a grid of cells, each with a character and colours. Text uses the terminal's own colour, so the graph reads on light and dark themes. On the desktop app, VS Code and mobile the pane shows the same tree as text.

Commands

CommandWhat it does
/q-graph or /q-graph openOpen the graph pane
/q-graph web or /q-graph open --webStart the live browser viewer and print its link (the same link while it runs)
/q-graph web stopStop the viewer
/q-graph closeClose it
/q-graph statusOne line per agent: model, status, time, tokens, uses, task; then the harness line
`/q-graph band on\off`Show or hide the roster band
`/q-graph notify on\off`Send a native notification when an agent waits for you or fails (the toast always shows)
/q-graph configShow the settings
/q-graph helpUsage

Subcommands are case-insensitive. An unknown subcommand or argument (for example web --stop) prints the usage with exit code 2 and does nothing.

Settings are saved across sessions. The graph itself belongs to the session.

Where the data comes from

Hook or callWhat q-graph records
agent.spawnA new node: agent type, model, task, parent agent, teammate address
tool.call (its agentId)Uses per agent: the tool, or the skill (Skill call's skill), or the MCP server (mcp__<server>__<tool>); the call running now, and whether it is asking the person (AskUserQuestion, ExitPlanMode)
classic.InstructionsLoaded (its agent_id, absent for main)A rule or CLAUDE.md file an agent loaded: at session start, for a path-matched rule, or for a nested CLAUDE.md
$.settings.read() and $.session.usage({ breakdown: 'summary' }), at session start and each promptThe harness card: settings hooks per event, memory files, skills, MCP servers, custom agents. summary counts locally and sends no request.
turn.complete (its agentId)Tokens (input, output and cache writes; cache reads are left out because every request re-reads the cached prefix); a subagent's turn finishes it (done, failed or stopped); main and teammates go idle
session.sendMessages between agents (SendMessage)
prompt.submitThe main loop starts working
$.agent.list(), once a second while an agent runs, waits or is startingStatuses no hook raises: waiting, idle, and failed or killed for a teammate; agents no spawn hook saw (a forked skill's loop) are added. A spawn or a message starts the poll again.

How the live viewer works

The mod cannot run a server (no Node in a mod), so /q-graph web starts viewer/server.mjs with $.process.spawn, trying node and then bun. The server makes a private temp folder, listens on 127.0.0.1 on a free port, and prints { port, file, token }. Every 300 ms the mod writes the graph to that file when it changed; the server pushes each new snapshot to the page over Server-Sent Events.

  • Only this machine can reach it (127.0.0.1), and every request needs the random token in the link. A request whose Host is not 127.0.0.1:<port> or localhost:<port> gets 403, which stops DNS rebinding.
  • Every response carries a Content-Security-Policy (scripts only from the page, cdnjs and jsDelivr; images only from the page; connections only to the viewer), X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and Cache-Control: no-store. At most 16 tabs can be connected at once.
  • The snapshot's transcript is used only when it is a .jsonl file under ~/.claude/projects.
  • The viewer stops with /q-graph web stop, when the session ends, when the plugin reloads, or when Claude Code exits (it checks its parent every 5 seconds). It deletes its temp folder on the way out, also after an uncaught error. A temp folder left behind by a killed viewer is swept by the next viewer once it is 24 hours old. The folder and the snapshot file are private to you (0700 and 0600).
  • A failed snapshot write is retried on the next tick, and a missing transcript is looked up again every 5 seconds.
  • The page loads React and React Flow from cdnjs and jsDelivr, so the browser needs internet access; the session data never leaves the machine. Each script tag has an SRI integrity hash, so bumping a library version means recomputing it.
  • An open tab reloads itself when viewer/index.html changes on disk (the server watches the file and sends a reload event), so a page update shows without a manual refresh. A viewer process keeps the server.mjs it started with: after updating the plugin, run /q-graph web stop and /q-graph web once to pick up new server routes (the Timeline says so when its route is missing).
  • Colours and fonts come from viewer/q-theme.css, a generated copy of the repo's shared design tokens (--q-*; edit shared/ui/q-theme.css and run node scripts/sync-ui.mjs). Light and dark follow the browser. Kind colours mark graph kinds only; links, focus and selection use the accent, and a running agent's status is the accent. Fonts are the system stacks, nothing is downloaded. The terminal pane uses the host theme's success, warning and error for status and one mid-tone hex set for kinds, so it reads on light and dark terminals.
  • The link works in a terminal. Remote surfaces (the desktop and mobile apps) only open https: links.

Timeline

The Graph | Timeline switch in the header (remembered in this browser) turns the canvas into a timeline of the whole session. Each agent is a lane: main on top, every subagent and teammate under the agent that spawned it. On each lane:

  • a thin bar for the agent's lifetime;
  • every tool, skill and MCP call as a block in its kind's colour (an Agent call takes the child's colour), with a red outline on errors and a moving stripe while it runs; calls that ran at the same time stack into extra rows;
  • a link from each Agent call down to the lane it started;
  • on main, a ◇ marker for each of your prompts.

Wheel scrolls, Ctrl/Cmd+wheel (or pinch) zooms around the pointer, and −, + and Fit zoom too. Follow live keeps "now" (the green line) in view. Compress idle (on by default) squeezes any stretch over 60 s with no activity on any lane into a hatched band labelled like "⋯ 16m idle". Hover a block for its tool, hint, start and duration. Click a block to open it in the detail sidebar with that call expanded (an Agent block opens the child agent), and click a lane's name to open the agent.

The lanes come from the transcripts, not from the graph: GET /timeline reads <session>.jsonl and every <session>/subagents/agent-*.jsonl with its .meta.json, finds each lane's parent through the meta's toolUseId (the Agent call that started it), and returns slim calls with no inputs or outputs. So agents that ran before the viewer opened still show. It keeps its own slim per-file index (no inputs or outputs), updated incrementally, so hundreds of subagents are parsed once, not on every poll. Both caches are bounded by size; the timeline keeps at most 5,000 calls per agent and the detail 2,000, and reports what it dropped in truncated. The page polls it at most every 2 seconds while something runs. The toolbar and the header both count agents including main.

Node details

The sidebar on the right (a bottom sheet below 760 px wide) is pinned by default: it opens with the page showing main, and Escape or a click on the empty canvas goes back to main instead of closing it. Click any node to show that node. The ◉ button unpins it (then Escape and a canvas click close it, as before) and ○ pins it again; × closes it and unpins. The pin is remembered in this browser. Drag its left edge to make it wider or narrower (or focus the edge and use ←/→, Shift for bigger steps); the ⤢ button, a double-click on the edge, or Enter on it toggles between the normal width and 60% of the window. The width is remembered in this browser.

NodeThe sidebar shows
Agent (main, subagent, teammate)Type, model, status, time, tokens, parent, task, its uses and child agents (all clickable), its prompt and final result, and a timeline of every tool call
Tool, skill or MCP useIts owner agent, the call count and every call of that use
RuleThe path (repo-relative or ~/…, the full path on hover) and the agent that loaded it
HarnessThe full lists: rule files, hooks per event, MCP servers, skill and agent counts

Each call row shows the time, the tool, a one-line hint (command, file path, pattern), the duration and ✓, ✗ or … while it runs; click it for the full input and output. An Agent call row has a separate → child link to the agent it started, and its "Async agent launched…" output shows as spawned → <child> (the raw text folds under it). The calls heading folds the whole list like prompt and result: it starts open, stays as you left it across nodes (remembered in this browser), and opens again when you click a call in the timeline.

An agent's prompt and result, and the prompt and result of an Agent call, are rendered as markdown (headings, lists, tables, code). The page renders them with marked and sanitizes the HTML with DOMPurify, which also strips images, media, styles, frames and forms so transcript content cannot load anything from the web; links open in a new tab. The md | raw switch in a block's corner shows the rendered or the plain text, and the choice is remembered in this browser.

Other tools' inputs and outputs get the same toggle:

  • An output that reads as markdown (two or more kinds of markdown syntax, such as a heading plus a table, as in cat README.md) is rendered first and follows the remembered choice. Any other output (plain logs, ls, diffs, line-numbered Read output) and every error starts raw, and its md button previews that block only.
  • A JSON input (SendMessage, WebFetch, MCP tools…) has an md view that lists its fields: long text as markdown, short values as code, objects as JSON. It is rendered first when one of its fields reads as markdown.
  • A one-field input (a Bash command, a file path, a Grep pattern) stays verbatim.

If either library fails to load from the CDN, every block shows plain text.

Nothing extra is stored: the details come from the transcripts Claude Code already writes, read when you click. The mod puts the main transcript's path in the snapshot (transcript, taken from a classic hook's transcript_path, else ~/.claude/projects/<project>/<session>.jsonl). On a click the page calls GET /detail?agent=<id>[&use=<key>]; the server reads <session>.jsonl for main and <session>/subagents/agent-<id>.jsonl for a subagent or teammate, pairs each tool_use with its tool_result by id, follows outputs Claude Code saved to tool-results/, and caps long strings (input 4,000 and output 8,000 characters; a saved output file is read only up to that cap). A transcript replaced by a new file (new inode) is re-read from the start. It keeps one index per file and reads only the bytes appended since the last request, so a 20 MB transcript costs about 40 ms once, then a few ms. While the selected agent runs, the sidebar refreshes at most every 2 seconds.

The transcript format is Claude Code's own and not a documented API: the parser skips anything it does not know, so a format change shows less detail rather than breaking the viewer. Transcripts are kept for Claude Code's cleanupPeriodDays (30 days by default).

Limits

  • Tokens are counted when an agent's turn ends, so a running subagent shows no tokens until it finishes (— in the browser viewer, blank on the pane card, 0 in /q-graph status).
  • An agent becomes lost only when it is missing from a non-empty $.agent.list(), has no call in flight and no hook named it for 45 seconds. Workflow agents are never listed, so for them only the silence counts. Only turn.complete finishes an agent; a late tool call does not restart a finished subagent.
  • A hook event from an agent seen before its spawn creates a stub card (type unknown) that the spawn fills in.
  • The notification is sent whether or not the terminal has the focus; your notification channel decides what to do with it. Turn it off with /q-graph notify off (the toast stays).
  • Agents started by a workflow are not in $.agent.list(); q-graph only sees what its hooks see of them.
  • A teammate running in its own terminal pane reports only running or idle, not its tool calls.
  • The moving dots repaint about 7 times a second while an agent runs, waits or is starting and the pane is shown, and an unchanged frame is not sent again.
  • Characters a terminal cell cannot hold in one column (emoji, CJK) are drawn as ?, one ? per run; combining marks, joiners and variation selectors are dropped. Cut text ends in …. The status and arrow glyphs (● ◐ ▸ ◂) are ambiguous-width, so a terminal set to draw those wide (some CJK setups) misaligns the cards.
  • One terminal Raster holds at most 512 columns by 256 rows, so a taller tree is drawn as several Rasters stacked in the pane. Past 4,096 rows, or a tree too deep for 512 columns, the agents that do not fit are counted under the graph (+N agents too deep or too many to draw here) with a show all in browser button that opens the live viewer, which has no size limit.
  • Hooks are on the harness card only: the engine does not say which agent a hook fired for, and the hooks modules of plugins (mods like this one) cannot be listed, so the card counts settings hooks.
  • Where an organization manages Claude Code, classic.* hooks of a plugin you installed yourself do not run, so the rule rows under each card are missing; the harness card still lists the memory files.
  • A subagent started without CLAUDE.md loads none, so it shows no rule rows.

Roadmap

See the design artifact for mockups.

VersionAdds
0.2Team view with dagre layout, a time slider in the browser viewer, Mermaid export, an SVG + SMIL pane for the desktop app
0.3Decision nodes from AskUserQuestion and ExitPlanMode written to docs/adr/
0.4Draft decisions extracted by Haiku, decisions fed back through prompt.compose, cost per agent type, pixel images for kitty and Ghostty

Install

/plugin install q-graph --marketplace phuquocchamp/q-harness

Develop

claude --plugin-dir ./plugins/q-graph       # load from disk; saving a file reloads it
claude plugin validate ./plugins/q-graph
claude plugin test ./plugins/q-graph

hooks/register.tsx holds every hook, the state atoms and every helper that takes $. hooks/lib/ holds pure helpers:

FileHolds
graph.tsThe session graph and its reducers (spawn, tool calls and their use keys, rules loaded, turn end, messages, sync with $.agent.list()), the harness card, status lines
layout.tsThe left-to-right tree layout in terminal cells: a band of rows per agent for its card and uses, the harness card on top
raster.tsDraws a laid-out graph into RasterProps.cells: framed cards, box-drawing edges routed at right angles, the uses under each card, the harness card, moving dots; splitFrame cuts a tall frame into stacked tiles of at most 256 rows
settings.tsDefaults, /q-graph toggles, usage text

viewer/ is the live browser viewer: server.mjs (Node 18+ or Bun, no dependencies), transcript.mjs (the transcript parser and incremental index behind /detail and /timeline), index.html (React Flow page, the timeline view and the detail sidebar; React, React Flow, marked and DOMPurify load from cdnjs and jsDelivr), reactflow-base.css (React Flow 11.11.4's base styles, MIT).

Session state (types/index.d.ts): graph, every agent and message of the session; settings, the only value saved across sessions.

Tests live in tests/: lib-graph and lib-raster for the pure helpers, recorder for the hooks, band, pane, alerts and commands, web for starting and stopping the live viewer and the snapshot's transcript; host.ts is the shared fake host. viewer/transcript.test.mjs and viewer/server.test.mjs run outside the plugin test kit (claude plugin test only runs tests/): node --test viewer/*.test.mjs (a bare directory argument fails).

Source 6 files
hooks/register.tsx 696 lines
1// q-graph: records every agent loop of the session (main, subagents, teammates) from the hooks,
2// shows the live ones in a band above the prompt, warns when one waits for the person, and draws
3// the whole tree in a pane: a terminal Raster with box-drawing edges, a dot on each running edge,
4// under each agent what it used (tools, skills, MCP servers, rules), and the session's harness.
5// Every hook and every helper that takes `$` lives in this file (the engine follows `$` only within
6// one module); pure helpers are in ./lib.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, ThemeKey, Timer } from 'claude-code'
10
11import type { AgentNode, GraphSettings, NodeStatus, SessionGraph } from '../types'
12import {
13  MAIN,
14  describeAgents,
15  emptyGraph,
16  formatElapsed,
17  formatTokens,
18  harnessFrom,
19  harnessLines,
20  hasLiveAgents,
21  needsYou,
22  RECENT_MS,
23  ASK_TOOLS,
24  messageSent,
25  promptSubmitted,
26  resolveRecipient,
27  rosterAgents,
28  ruleLoaded,
29  setHarness,
30  spawnAgent,
31  syncAgents,
32  toolFinished,
33  toolStarted,
34  turnEnded,
35  useKey,
36  type Transition,
37} from './lib/graph'
38import { layoutGraph } from './lib/layout'
39import { renderFrame, splitFrame } from './lib/raster'
40import { DEFAULT_SETTINGS, USAGE, parseToggle, pickSettings, showSettings, unknownArg } from './lib/settings'
41
42const graph = atom({ plugin: 'q-graph', key: 'graph' } as const, emptyGraph() as SessionGraph)
43const settings = atom({ plugin: 'q-graph', key: 'settings' } as const, DEFAULT_SETTINGS as GraphSettings)
44const webUrl = atom({ plugin: 'q-graph', key: 'webUrl' } as const, null as string | null)
45/** The band lists every agent on wrapped rows instead of what fits one row (its `+N more` button). */
46const bandAll = atom({ plugin: 'q-graph', key: 'bandAll' } as const, false as boolean)
47
48const PANE = 'q-graph'
49const SETTINGS_KEY = 'settings'
50const POLL_MS = 1000
51const FRAME_MS = 150
52/** How often the live viewer's snapshot file is refreshed (only written when the graph changed). */
53const WEB_MS = 300
54/** How long a failed transcript lookup is remembered before the next try. */
55const TRANSCRIPT_RETRY_MS = 5000
56/** How long the viewer process may take to print its address. */
57const WEB_START_MS = 8000
58/** Runtimes tried in turn for the viewer process. */
59const RUNTIMES = ['node', 'bun']
60/** The system's "open this URL" commands, tried in order: macOS, Linux, Windows. */
61const OPENERS = [['open'], ['xdg-open'], ['cmd', '/c', 'start', '""']]
62
63// Kinds need hex (the theme has no kind colours): mid-luminance, readable on light and dark terminals.
64// Status uses the host theme's keys, so it follows the terminal theme.
65const KIND_HEX = { main: '#3969de', subagent: '#1f966f', teammate: '#a72f83' } as const
66const STATUS_COLOR: Partial<Record<NodeStatus, ThemeKey>> = { completed: 'success', failed: 'error' }
67const WARN_COLOR: ThemeKey = 'warning'
68const SPINNER = '◐◓◑◒'
69
70async function saveSettings($: EngineInterface, next: GraphSettings) {
71  await update($, settings, () => next)
72  await $.store.set(SETTINGS_KEY, next)
73}
74
75function openPane($: EngineInterface) {
76  return $.ui.open({ id: PANE, title: 'q-graph' })
77}
78
79/** The one-line state of an agent in the band. */
80const bandStatus = (a: AgentNode, now: number) => {
81  if (needsYou(a)) return `⚠ needs you ${formatElapsed(now - (a.waitingSince ?? now))}`
82  if (a.status === 'running') return `${SPINNER[Math.floor(now / 1000) % SPINNER.length]} ${a.currentTool ?? formatElapsed(now - a.startedAt)}`
83  if (a.status === 'waiting') return '⏸ waiting'
84  if (a.status === 'completed') return `✓ ${formatElapsed((a.endedAt ?? now) - a.startedAt)}`
85  if (a.status === 'failed') return '✗ failed'
86  if (a.status === 'idle') return '○ idle'
87  if (a.status === 'killed') return '■ stopped'
88  if (a.status === 'lost') return '? lost'
89  return '· starting'
90}
91
92// Module timers: a reload starts them over, and the engine cancels the old ones with the module.
93let poll: Timer | null = null
94/** Counts every ensurePoll call, so a tick can tell that one arrived while it ran. */
95let pollWanted = 0
96/** True while the sleep that redraws the band after the last finished chips expire is pending. */
97let expiryPending = false
98let frames: Timer | null = null
99/** The pane's width at its last draw: the frames between draws lay the graph out for it. */
100let paneRoom = 80
101/** The cells last blitted, per tile: an unchanged tile is not sent again. */
102let lastCells: string[] = []
103let framesBusy = false
104
105/** Shows a toast, and a native notification when that setting is on. Callers do not wait for it. */
106function alert($: EngineInterface, a: AgentNode, text: string) {
107  void (async () => {
108    $.ui.toast(`q-graph: ${text}`, { timeoutMs: 8000 })
109    if ((await read($, settings)).notify) await $.ui.notify(a.description ? `${text}: ${a.description}` : text, { title: 'q-graph' })
110  })().catch(() => undefined)
111}
112
113/** Tells the person about an agent that failed; one first seen already failed is not news. */
114function announce($: EngineInterface, g: SessionGraph, transitions: readonly Transition[]) {
115  for (const t of transitions) {
116    const a = g.agents[t.id]
117    if (a && t.to === 'failed' && t.from !== null) alert($, a, `${a.label} failed`)
118  }
119}
120
121/**
122 * Reads what the session's harness holds for the harness card: the settings hooks, and from the
123 * context the memory files, skills, MCP servers and custom agents. `summary` counts locally, so the
124 * read sends no request.
125 */
126async function refreshHarness($: EngineInterface) {
127  try {
128    const [cfg, usage] = await Promise.all([$.settings.read(), $.session.usage({ breakdown: 'summary' })])
129    const harness = harnessFrom(cfg as { hooks?: unknown }, usage.context.breakdown)
130    await update($, graph, g => setHarness(g, harness))
131  } catch {
132    // The card keeps what it last read.
133  }
134}
135
136/** The harness card as one status line: `harness · rules 2: CLAUDE.md, … · hooks 1 · …`. */
137const harnessStatus = (g: SessionGraph) => (g.harness ? `harness · ${harnessLines(g.harness).join(' · ')}` : null)
138
139/** Reads the engine's agent list once a second while agents are alive, for statuses no hook raises (waiting, idle). */
140function ensurePoll($: EngineInterface) {
141  pollWanted++
142  if (poll) return
143  let busy = false
144  poll = $.clock.every(POLL_MS, async () => {
145    // A tick that is still waiting on the list is not overlapped.
146    if (busy) return
147    busy = true
148    const wanted = pollWanted
149    try {
150      const list = await $.agent.list()
151      const now = await $.clock.now()
152      // The sync runs inside the updater, so a retry after another hook's write works on that write.
153      let transitions: Transition[] = []
154      const after = await update($, graph, g => {
155        const r = syncAgents(g, list, now)
156        transitions = r.transitions
157        return r.graph
158      })
159      announce($, after, transitions)
160      const live = hasLiveAgents(after)
161      // The band and the pane show elapsed time, so they redraw each tick while something is alive.
162      if (live || transitions.length > 0) $.ui.invalidate('ui.render')
163      // A spawn, a message or a tool call starts the poll again; one that arrived during this tick keeps it.
164      if (!live && wanted === pollWanted) {
165        poll?.cancel()
166        poll = null
167        expireChips($)
168      }
169    } catch {
170      // A failed list read skips this tick; the next one tries again.
171    } finally {
172      busy = false
173    }
174  })
175}
176
177/** Redraws once the finished chips of the band have expired, which no poll is left to do. */
178function expireChips($: EngineInterface) {
179  if (expiryPending) return
180  expiryPending = true
181  void $.clock
182    .sleep(RECENT_MS)
183    .then(() => $.ui.invalidate('ui.render'))
184    .catch(() => undefined)
185    .finally(() => {
186      expiryPending = false
187    })
188}
189
190/** Repaints the pane's Raster in place while an agent runs, so the dots move without a redraw. */
191function ensureFrames($: EngineInterface) {
192  if (frames) return
193  const stop = () => {
194    frames?.cancel()
195    frames = null
196  }
197  frames = $.clock.every(FRAME_MS, async () => {
198    if (framesBusy) return
199    framesBusy = true
200    try {
201      const g = await read($, graph)
202      if (!hasLiveAgents(g)) {
203        stop()
204        $.ui.invalidate('ui.render')
205        return
206      }
207      const now = await $.clock.now()
208      const tiles = splitFrame(renderFrame(g, layoutGraph(g, paneRoom), now, Math.floor(now / FRAME_MS)))
209      // More or fewer tiles than are mounted: only a redraw can add or drop a Raster.
210      if (tiles.length !== lastCells.length) {
211        stop()
212        $.ui.invalidate('ui.render')
213        return
214      }
215      for (const [i, t] of tiles.entries()) {
216        if (t.cells === lastCells[i]) continue
217        lastCells[i] = t.cells
218        const r = await $.ui.blit({ requestId: PANE, key: `graph-${i}`, cells: t.cells, columns: t.columns, rows: t.rows })
219        // Not mounted, another tab shown, or the size changed: the next draw starts the frames again.
220        if (r.deny) return stop()
221      }
222    } catch {
223      stop()
224    } finally {
225      framesBusy = false
226    }
227  })
228}
229
230// ---- live browser viewer -------------------------------------------------------------------------
231
232type Viewer = { url: string; file: string; child: AsyncGenerator<unknown, unknown> }
233let viewer: Viewer | null = null
234let webWriter: Timer | null = null
235let lastSnapshot = ''
236/** The start in progress, shared by concurrent callers so two never spawn two servers. */
237let starting: Promise<string | { error: string }> | null = null
238let writing = false
239/** A transcript lookup that found nothing, kept for TRANSCRIPT_RETRY_MS. */
240let transcriptMiss: { id: string; until: number } | null = null
241
242type Ready = { port: number; file: string; token: string }
243
244/** Reads the child's stdout until its first line, `{ ready, port, file, token }`. */
245async function readReady(child: AsyncGenerator<{ stream: string; text: string }, unknown>): Promise<Ready | null> {
246  let out = ''
247  for (;;) {
248    const step = await child.next()
249    if (step.done) return null
250    if (step.value.stream !== 'stdout') continue
251    out += step.value.text
252    const line = out.split('\n')[0]
253    if (out.includes('\n') && line) {
254      const parsed = JSON.parse(line) as Partial<Ready> & { ready?: boolean }
255      return parsed.ready && parsed.port && parsed.file && parsed.token ? (parsed as Ready) : null
256    }
257  }
258}
259
260/**
261 * The main session's transcript (JSONL), as the last main-loop classic hook reported it in
262 * `transcript_path`; null until one fired. The viewer reads node details from it on demand.
263 */
264let transcript: string | null = null
265
266/** Keeps a classic hook's `transcript_path` when the main loop raised it (no `agent_id`). */
267function noteTranscript(e: { transcript_path?: string; agent_id?: string }) {
268  const path = e.transcript_path
269  if (!e.agent_id && typeof path === 'string' && path.endsWith('.jsonl')) transcript = path
270}
271
272const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
273
274/**
275 * The main transcript of session `id`: the noted path when its file name is the session id, else
276 * where Claude Code stores it, `~/.claude/projects/<root with every non-alphanumeric as ->/<id>.jsonl`,
277 * when that file exists; null otherwise. Never throws.
278 */
279async function transcriptFor($: EngineInterface, id: string): Promise<string | null> {
280  if (transcript && baseName(transcript) === `${id}.jsonl`) return transcript
281  const now = await $.clock.now().catch(() => 0)
282  if (transcriptMiss?.id === id && now < transcriptMiss.until) return null
283  const miss = () => {
284    transcriptMiss = { id, until: now + TRANSCRIPT_RETRY_MS }
285    return null
286  }
287  try {
288    const [home, root] = await Promise.all([$.env.get('HOME'), $.session.root()])
289    if (!home || !id) return miss()
290    const guess = `${home}/.claude/projects/${root.replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`
291    if (!(await $.fs.exists(guess))) return miss()
292    transcript = guess
293    return guess
294  } catch {
295    return miss()
296  }
297}
298
299/** Writes the graph for the viewer whenever it changed. */
300async function writeSnapshot($: EngineInterface) {
301  // Ticks do not overlap: a slow write is not started again underneath itself.
302  if (!viewer || writing) return
303  writing = true
304  try {
305    const file = viewer.file
306    const session = await $.session.id()
307    const text = JSON.stringify({ session, transcript: await transcriptFor($, session), graph: await read($, graph) })
308    if (text === lastSnapshot) return
309    await $.fs.write(file, text)
310    // Only a written snapshot counts as sent, so a failed write is tried again on the next tick.
311    lastSnapshot = text
312  } finally {
313    writing = false
314  }
315}
316
317/** Ends the viewer process and forgets its address. */
318async function stopViewer($: EngineInterface) {
319  const was = viewer
320  viewer = null
321  webWriter?.cancel()
322  webWriter = null
323  lastSnapshot = ''
324  transcriptMiss = null
325  if (was) await was.child.return(undefined as never).catch(() => undefined)
326  await update($, webUrl, () => null)
327}
328
329/**
330 * Starts the viewer (viewer/server.mjs) with the first runtime that runs, and answers its address.
331 * Concurrent callers share one start. The child lives as long as the loop that drains it: the module
332 * unloading, `stopViewer` or the child exiting ends it.
333 */
334function startViewer($: EngineInterface): Promise<string | { error: string }> {
335  if (viewer) return Promise.resolve(viewer.url)
336  return (starting ??= launchViewer($).finally(() => {
337    starting = null
338  }))
339}
340
341async function launchViewer($: EngineInterface): Promise<string | { error: string }> {
342  const script = `${$.plugin.root}/viewer/server.mjs`
343  const tried: string[] = []
344  for (const runtime of RUNTIMES) {
345    let child: ReturnType<EngineInterface['process']['spawn']> | undefined
346    let ready: Ready | null = null
347    let why = ''
348    try {
349      child = $.process.spawn({ argv: [runtime, script] })
350      const got = await Promise.race([readReady(child), $.clock.sleep(WEB_START_MS).then(() => 'timeout' as const)])
351      if (got === 'timeout') why = `no address within ${WEB_START_MS / 1000}s`
352      else if (got) ready = got
353      else why = 'exited before printing its address'
354    } catch (err) {
355      // The runtime is missing, or the child printed something that is not its address.
356      why = err instanceof Error ? err.message : String(err)
357    }
358    if (!ready || !child) {
359      tried.push(`${runtime}: ${why}`)
360      await child?.return(undefined as never).catch(() => undefined)
361      continue
362    }
363    const started = child
364    const url = `http://127.0.0.1:${ready.port}/?k=${ready.token}`
365    const mine: Viewer = { url, file: ready.file, child: started }
366    viewer = mine
367    await update($, webUrl, () => url)
368    await writeSnapshot($).catch(() => undefined)
369    webWriter?.cancel()
370    webWriter = $.clock.every(WEB_MS, () => void writeSnapshot($).catch(() => undefined))
371    // Drain the child's output for its whole life; when it exits, forget it.
372    void (async () => {
373      try {
374        while (!(await started.next()).done) {
375          // The viewer prints nothing after its address; anything else is ignored.
376        }
377      } catch {
378        // Killed or failed: handled below.
379      }
380      if (viewer === mine) await stopViewer($).catch(() => undefined)
381    })()
382    return url
383  }
384  return { error: `could not start the viewer (${tried.join('; ')}). It needs Node 18+ or Bun on PATH.` }
385}
386
387/** Starts the viewer if needed and opens it in the default browser; a toast says what failed. */
388async function openInBrowser($: EngineInterface) {
389  const web = await startViewer($)
390  if (typeof web !== 'string') return void $.ui.toast(`q-graph: ${web.error}`, { timeoutMs: 8000 })
391  for (const opener of OPENERS) {
392    try {
393      const r = await $.process.run([...opener, web], { timeoutMs: 5000 })
394      if (r.exitCode === 0) return
395    } catch {
396      // Not on this system: try the next opener.
397    }
398  }
399  $.ui.toast(`q-graph: could not open a browser; visit ${web}`, { timeoutMs: 8000 })
400}
401
402export const register: Register = on => {
403  // ---- settings and /q-graph ------------------------------------------------------------------
404
405  on('session.start', async ($, e, next) => {
406    try {
407      const saved = await $.store.get(SETTINGS_KEY)
408      await update($, settings, () => pickSettings(saved))
409    } catch {
410      // Defaults stand when the store cannot be read.
411    }
412    // A reload ends the old module's viewer process with it; a live viewer of this module stays.
413    await update($, webUrl, () => viewer?.url ?? null).catch(() => undefined)
414    await $.command.register({
415      name: 'q-graph',
416      description: 'Show the subagents and teammates of this session as a live graph',
417      argumentHint: 'open [--web] | web [stop] | close | status | band on|off | notify on|off | config | help',
418    })
419    await refreshHarness($)
420    return next(e)
421  })
422
423  on('command.run', { command: 'q-graph' }, async ($, e) => {
424    const [sub = 'open', ...rest] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
425    const extra = unknownArg(sub, rest)
426    if (extra !== undefined) return { text: `Unknown argument "${extra}" for "${sub}".\n${USAGE}`, exitCode: 2 }
427    switch (sub) {
428      case 'open': {
429        const r = await openPane($)
430        const pane = r.isPlaced ? 'q-graph pane opened.' : 'q-graph pane opens when there is room for it.'
431        if (!rest.includes('--web')) return { text: pane }
432        const web = await startViewer($)
433        return typeof web === 'string' ? { text: `${pane}\nLive viewer: ${web}` } : { text: `${pane}\n${web.error}`, exitCode: 1 }
434      }
435      case 'web': {
436        if (rest[0] === 'stop') {
437          await stopViewer($)
438          return { text: 'q-graph live viewer stopped.' }
439        }
440        const web = await startViewer($)
441        return typeof web === 'string' ? { text: `q-graph live viewer: ${web}` } : { text: web.error, exitCode: 1 }
442      }
443      case 'close':
444        await $.ui.close({ id: PANE })
445        return { text: 'q-graph pane closed.' }
446      case 'status': {
447        const g = await read($, graph)
448        const lines = describeAgents(g, await $.clock.now())
449        const harness = harnessStatus(g)
450        const agents = lines.length > 1 ? lines.join('\n') : 'No subagents or teammates in this session yet.'
451        return { text: harness ? `${agents}\n${harness}` : agents }
452      }
453      case 'band':
454      case 'notify': {
455        const patch = parseToggle(sub, rest[0] ?? '')
456        if (typeof patch === 'string') return { text: patch, exitCode: 2 }
457        await saveSettings($, { ...(await read($, settings)), ...patch })
458        return { text: `${sub} ${rest[0]}.` }
459      }
460      case 'config':
461        return { text: `q-graph settings:\n${showSettings(await read($, settings))}` }
462      case 'help':
463        return { text: USAGE }
464      default:
465        return { text: `Unknown subcommand "${sub}".\n${USAGE}`, exitCode: 2 }
466    }
467  })
468
469  // ---- event recorder ---------------------------------------------------------------------------
470
471  on('prompt.submit', async ($, e, next) => {
472    const now = await $.clock.now()
473    await update($, graph, g => promptSubmitted(g, now))
474    // MCP servers connect and skills load after the session starts: the card is read again each prompt.
475    void refreshHarness($)
476    return next(e)
477  }).catch(($, e, next) => next(e))
478
479  on('agent.spawn', async ($, e, next) => {
480    const r = await next(e)
481    if (r.agentId) {
482      const now = await $.clock.now()
483      await update($, graph, g =>
484        spawnAgent(
485          g,
486          {
487            id: r.agentId!,
488            parentId: e.parentAgentId ?? MAIN,
489            isTeammate: e.isTeammate === true,
490            type: e.subagentType,
491            name: e.name,
492            teammateId: r.teammateId,
493            model: r.model ?? e.model,
494            description: e.description,
495          },
496          now,
497        ),
498      )
499      ensurePoll($)
500    }
501    return r
502  }).catch(($, e, next) => next(e)) // `next` replays a settled spawn, so a failure here never starts the agent twice
503
504  on('tool.call', async ($, e, next) => {
505    const id = e.agentId ?? MAIN
506    const callId = e.tool_use_id ?? null
507    // A skill by name and an MCP server stand for the call; a spawn is the child's own card.
508    const key = useKey(e.tool, e as { skill?: unknown })
509    const now = await $.clock.now()
510    // An agent no spawn has recorded yet gets a stub that the spawn merges into.
511    const after = await update($, graph, g => toolStarted(g, id, e.tool, now, key, callId))
512    const agent = after.agents[id]
513    // A call by an agent that was not live (lost, or woken by a message) brings the poll back.
514    if (hasLiveAgents(after)) ensurePoll($)
515    // A subagent or teammate asking a question or for plan approval: the main loop's own asks are on screen already.
516    if (agent && agent.kind !== 'main' && ASK_TOOLS.has(e.tool)) {
517      alert($, agent, e.tool === 'ExitPlanMode' ? `${agent.label} waits for plan approval` : `${agent.label} is asking you`)
518    }
519    try {
520      return await next(e)
521    } finally {
522      const done = await $.clock.now().catch(() => 0)
523      const ended = await update($, graph, g => toolFinished(g, id, e.tool, done, key, callId)).catch(() => undefined)
524      if (ended && hasLiveAgents(ended)) ensurePoll($)
525    }
526  }).catch(($, e, next) => next(e))
527
528  // The main transcript's path, for the viewer's node details: a start, resume, /clear or fork
529  // names the session's file; every main-loop prompt repeats it.
530  on('classic.SessionStart', async ($, e, next) => {
531    const r = await next(e)
532    noteTranscript(e)
533    return r
534  }).catch(($, e, next) => next(e))
535
536  on('classic.UserPromptSubmit', async ($, e, next) => {
537    const r = await next(e)
538    noteTranscript(e)
539    return r
540  }).catch(($, e, next) => next(e))
541
542  // A CLAUDE.md or rules file loaded by an agent: at session start for main, on a path-matched rule
543  // or a nested CLAUDE.md for whichever agent read the file (`agent_id`, absent for main).
544  on('classic.InstructionsLoaded', async ($, e, next) => {
545    const r = await next(e)
546    noteTranscript(e)
547    const id = e.agent_id ?? MAIN
548    const now = await $.clock.now()
549    const after = await update($, graph, g => ruleLoaded(g, id, e.file_path, now))
550    if (hasLiveAgents(after)) ensurePoll($)
551    return r
552  }).catch(($, e, next) => next(e))
553
554  on('turn.complete', async ($, e, next) => {
555    const r = await next(e)
556    const id = e.agentId ?? MAIN
557    const u = e.usage
558    // Cache reads are left out: every request re-reads the cached prefix (see AgentNode.tokens).
559    const tokens = u ? (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) : 0
560    const now = await $.clock.now()
561    // Worked out inside the updater, so a retry sees the writes of the hooks that ran meanwhile.
562    let was: NodeStatus | null = null
563    const after = await update($, graph, g => {
564      was = g.agents[id]?.status ?? null
565      return turnEnded(g, id, { tokens, reason: e.reason, model: u?.model }, now)
566    })
567    const to = after.agents[id]?.status
568    if (to && to !== was) announce($, after, [{ id, from: was, to }])
569    return r
570  }).catch(($, e, next) => next(e))
571
572  on('session.end', async ($, e, next) => {
573    await stopViewer($).catch(() => undefined)
574    return next(e)
575  })
576
577  on('session.send', async ($, e, next) => {
578    const r = await next(e)
579    if (r.isDelivered) {
580      await update($, graph, g => {
581        const to = resolveRecipient(g, e.to)
582        return to ? messageSent(g, e.agentId ?? MAIN, to) : g
583      })
584      ensurePoll($)
585    }
586    return r
587  }).catch(($, e, next) => next(e))
588
589  // ---- band above the prompt -------------------------------------------------------------------
590
591  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
592    const cfg = await read($, settings)
593    if (!cfg.band || e.props.hasSurvey) return next(e)
594    const g = await read($, graph)
595    const now = await $.clock.now()
596    const agents = rosterAgents(g, now)
597    if (agents.length === 0) return next(e)
598
599    const { Box, Button, Text } = $.ui.resolve(e)
600    const all = await read($, bandAll)
601    const room = Math.max(20, e.props.bodyColumns - 12)
602    const chips: { a: AgentNode; label: string; status: string }[] = []
603    let width = 0
604    for (const a of agents) {
605      const status = bandStatus(a, now)
606      const tokens = a.tokens ? ` ${formatTokens(a.tokens)}` : ''
607      const chip = { a, label: a.label, status: status + tokens }
608      const w = chip.label.length + chip.status.length + 4
609      // Folded, the band keeps to one row; unfolded, every agent is listed and the row wraps.
610      if (!all && width + w > room && chips.length > 0) break
611      chips.push(chip)
612      width += w
613    }
614    const more = agents.length - chips.length
615    const setAll = (expand: boolean) => void update($, bandAll, () => expand).catch(() => undefined)
616
617    return (
618      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
619        {chips.map(c => (
620          <Text key={`chip-${c.a.id}`} wrap="truncate">
621            <Text color={KIND_HEX[c.a.kind]}>{c.label}</Text>{' '}
622            <Text color={needsYou(c.a) ? WARN_COLOR : STATUS_COLOR[c.a.status]} dimColor={!needsYou(c.a) && !STATUS_COLOR[c.a.status]}>
623              {c.status}
624            </Text>
625          </Text>
626        ))}
627        {more > 0 && <Button key="band-all" label={`+${more} more`} onPress={() => setAll(true)} />}
628        {all && width > room && <Button key="band-fold" label="show less" onPress={() => setAll(false)} />}
629        <Button key="open-graph" label="graph" onPress={() => void openPane($)} />
630      </Box>
631    )
632  })
633
634  // ---- the graph pane ---------------------------------------------------------------------------
635
636  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
637    const g = await read($, graph)
638    const now = await $.clock.now()
639    const { Box, Text } = $.ui.resolve(e)
640    const summary = (() => {
641      const all = g.order.map(id => g.agents[id]).filter((a): a is AgentNode => !!a && a.kind !== 'main')
642      const count = (s: NodeStatus) => all.filter(a => a.status === s).length
643      const tokens = all.reduce((n, a) => n + a.tokens, 0)
644      return `${all.length} agents · ${count('running')} running · ${all.filter(needsYou).length} need you · ${formatTokens(tokens)} tok`
645    })()
646
647    if (e.surface === 'terminal') {
648      const { Raster, Link } = $.ui.resolve(e)
649      const url = await read($, webUrl)
650      const { Button } = $.ui.resolve(e)
651      paneRoom = Math.max(40, e.props.bodyColumns)
652      const layout = layoutGraph(g, paneRoom)
653      // One Raster holds at most 256 rows: a taller tree is drawn as several, stacked.
654      const tiles = splitFrame(renderFrame(g, layout, now, Math.floor(now / FRAME_MS)))
655      lastCells = tiles.map(t => t.cells)
656      if (hasLiveAgents(g)) ensureFrames($)
657      const openWeb = () => void openInBrowser($).catch(() => undefined)
658      return (
659        <Box flexDirection="column" gap={1}>
660          <Box flexDirection="row" gap={2}>
661            <Text dimColor>{summary}</Text>
662            <Button key="open-web" label="↗ open in browser" onPress={openWeb} />
663            {url && <Link href={url} label={url} />}
664          </Box>
665          <Box flexDirection="column">
666            {tiles.map((t, i) => (
667              <Raster key={`graph-${i}`} columns={t.columns} rows={t.rows} cells={t.cells} />
668            ))}
669          </Box>
670          {layout.hidden > 0 && (
671            <Box flexDirection="row" gap={2}>
672              <Text color={WARN_COLOR}>+{layout.hidden} agents too deep or too many to draw here</Text>
673              <Button key="show-all" label="show all in browser" onPress={openWeb} />
674            </Box>
675          )}
676        </Box>
677      )
678    }
679
680    // Surfaces without a Raster get the same tree as text.
681    const harness = harnessStatus(g)
682    const lines = harness ? [...describeAgents(g, now), harness] : describeAgents(g, now)
683    return (
684      <Box flexDirection="column">
685        <Text dimColor>{summary}</Text>
686        {lines.map((line, i) => (
687          <Text key={`line-${i}`} wrap="truncate">
688            {line}
689          </Text>
690        ))}
691      </Box>
692    )
693  })
694}
695
696
hooks/lib/graph.ts 434 lines
1// The session graph: one node per agent loop (main, subagents, teammates) and the messages between
2// them. Pure reducers, so every hook in register.tsx is a one-line update and everything is testable.
3
4import type { AgentKind, AgentNode, Harness, InFlightCall, NodeStatus, SessionGraph, UseKind } from '../../types'
5
6export const MAIN = 'main'
7
8/** How long a finished agent stays on the band. */
9export const RECENT_MS = 30_000
10
11const DONE: ReadonlySet<NodeStatus> = new Set(['completed', 'failed', 'killed'])
12
13export const isDone = (s: NodeStatus) => DONE.has(s)
14
15/** How long a live agent may be absent from the engine's list and silent before it counts as lost. */
16export const LOST_MS = 45_000
17
18const LIVE: ReadonlySet<NodeStatus> = new Set(['running', 'waiting', 'pending'])
19
20/** Tools that hold an agent until the person (or the lead, for a teammate's plan) answers. */
21export const ASK_TOOLS: ReadonlySet<string> = new Set(['AskUserQuestion', 'ExitPlanMode'])
22
23/**
24 * True while an agent other than main waits on an answer. The engine's own `waiting` status is not
25 * enough: it also covers an agent held on its background work or on a child agent.
26 */
27export const needsYou = (a: AgentNode) => a.kind !== 'main' && askingTool(a) !== undefined
28
29/** The ask tool an agent has open (the latest one), or undefined. */
30export const askingTool = (a: AgentNode): string | undefined => (a.inFlight ?? []).findLast(c => ASK_TOOLS.has(c.tool))?.tool
31
32const mainNode = (now: number): AgentNode => ({
33  id: MAIN,
34  parentId: null,
35  kind: 'main',
36  type: 'main',
37  label: 'main',
38  model: '',
39  description: '',
40  status: 'idle',
41  startedAt: now,
42  endedAt: null,
43  waitingSince: null,
44  tokens: 0,
45  toolCalls: 0,
46  tools: {},
47  currentTool: null,
48  currentUse: null,
49  inFlight: [],
50  lastEventAt: now,
51  toolAt: {},
52})
53
54export const emptyGraph = (now = 0): SessionGraph => ({ agents: { [MAIN]: mainNode(now) }, order: [MAIN], messages: [], harness: null })
55
56/** How long a tool's edge stays lit after a call starts or ends, so a fast call is still seen. */
57export const TOOL_GLOW_MS = 1200
58
59/** The graph with one agent replaced; an unknown id leaves it as it is. */
60const patch = (g: SessionGraph, id: string, fn: (a: AgentNode) => AgentNode): SessionGraph => {
61  const a = g.agents[id]
62  return a ? { ...g, agents: { ...g.agents, [id]: fn(a) } } : g
63}
64
65/**
66 * The graph with agent `id` present: one a hook names before `agent.spawn` has recorded it gets a
67 * stub (under main, type `unknown`) that the later spawn merges into.
68 */
69const withAgent = (g: SessionGraph, id: string, now: number): SessionGraph =>
70  g.agents[id]
71    ? g
72    : { ...g, agents: { ...g.agents, [id]: { ...mainNode(now), id, parentId: MAIN, kind: 'subagent', type: 'unknown', label: id, status: 'running' } }, order: [...g.order, id] }
73
74/** A hook named this agent: a lost one runs again, and a finished subagent stays finished. */
75const heard = (a: AgentNode, now: number): AgentNode =>
76  a.status === 'lost' ? { ...a, status: 'running', endedAt: null, lastEventAt: now } : { ...a, lastEventAt: now }
77
78export type Spawned = {
79  id: string
80  parentId?: string | null
81  isTeammate?: boolean
82  type: string
83  name?: string
84  teammateId?: string
85  model?: string
86  description: string
87}
88
89export const labelFor = (s: Pick<Spawned, 'type' | 'name' | 'teammateId'>) => s.teammateId ?? s.name ?? s.type
90
91/** True when `id` is `ancestor` or sits somewhere under it. */
92const isUnder = (g: SessionGraph, id: string, ancestor: string): boolean => {
93  const seen = new Set<string>()
94  for (let cur: string | null | undefined = id; cur && !seen.has(cur); cur = g.agents[cur]?.parentId) {
95    if (cur === ancestor) return true
96    seen.add(cur)
97  }
98  return false
99}
100
101export const spawnAgent = (g: SessionGraph, s: Spawned, now: number): SessionGraph => {
102  // A parent that is the agent itself or one of its descendants would make a cycle: main takes it.
103  const parentId = s.parentId && g.agents[s.parentId] && !isUnder(g, s.parentId, s.id) ? s.parentId : MAIN
104  const kind: AgentKind = s.isTeammate ? 'teammate' : 'subagent'
105  const prev = g.agents[s.id]
106  // A stub that already finished (its turn.complete beat this spawn) stays finished.
107  const settled = prev && prev.type === 'unknown' && isDone(prev.status)
108  const node: AgentNode = {
109    ...(prev ?? mainNode(now)),
110    id: s.id,
111    parentId,
112    kind,
113    type: s.type,
114    label: labelFor(s),
115    model: s.model ?? prev?.model ?? '',
116    description: s.description,
117    status: settled ? prev.status : 'running',
118    startedAt: prev?.startedAt ?? now,
119    endedAt: settled ? prev.endedAt : null,
120    waitingSince: null,
121    lastEventAt: now,
122  }
123  return { ...g, agents: { ...g.agents, [s.id]: node }, order: prev ? g.order : [...g.order, s.id] }
124}
125
126/** The main loop starts a turn; its clock starts at the session's first prompt. */
127export const promptSubmitted = (g: SessionGraph, now = 0): SessionGraph =>
128  patch(g, MAIN, a => ({ ...a, status: 'running', endedAt: null, startedAt: a.startedAt > 0 ? a.startedAt : now }))
129
130/** Tools that start an agent: the child's own card stands for the call, so it is not listed as a use. */
131const SPAWN_TOOLS: ReadonlySet<string> = new Set(['Agent', 'Task'])
132
133/**
134 * What a tool call uses, as the graph files it: a skill by name, an MCP server, or the tool itself;
135 * null for a call that starts an agent.
136 */
137export const useKey = (tool: string, input: { skill?: unknown } = {}): string | null => {
138  if (SPAWN_TOOLS.has(tool)) return null
139  if (tool === 'Skill' && typeof input.skill === 'string' && input.skill) return `skill:${input.skill}`
140  if (tool.startsWith('mcp__')) return `mcp:${tool.slice(5).split('__')[0] || tool}`
141  return tool
142}
143
144/** A use key's kind and the name the graph shows: `skill:commit` → skill `commit`, a rule → its file name. */
145export const useOf = (key: string): { kind: UseKind; label: string } => {
146  const i = key.indexOf(':')
147  const prefix = i > 0 ? key.slice(0, i) : ''
148  if (prefix === 'skill' || prefix === 'mcp') return { kind: prefix, label: key.slice(i + 1) }
149  if (prefix === 'rule') return { kind: 'rule', label: baseName(key.slice(i + 1)) }
150  return { kind: 'tool', label: key }
151}
152
153const baseName = (path: string) => path.split(/[\\/]/).pop() || path
154
155/** The calls still in flight, and what they leave as the agent's current tool. */
156const withCalls = (a: AgentNode, inFlight: InFlightCall[]): AgentNode => {
157  const last = inFlight[inFlight.length - 1]
158  return { ...a, inFlight, currentTool: last?.tool ?? null, currentUse: last?.use ?? null }
159}
160
161export const toolStarted = (
162  g: SessionGraph,
163  id: string,
164  tool: string,
165  now = 0,
166  key: string | null = useKey(tool),
167  callId: string | null = null,
168): SessionGraph =>
169  patch(withAgent(g, id, now), id, prev => {
170    const a = heard(prev, now)
171    return {
172      ...withCalls(a, [...(a.inFlight ?? []), { callId, tool, use: key }]),
173      waitingSince: ASK_TOOLS.has(tool) ? now : a.waitingSince,
174      toolCalls: a.toolCalls + 1,
175      ...(key ? { tools: { ...a.tools, [key]: (a.tools[key] ?? 0) + 1 }, toolAt: { ...(a.toolAt ?? {}), [key]: now } } : {}),
176      // A tool call is proof the loop runs: a teammate woken by a message, the main loop mid-turn.
177      // A finished subagent stays finished (only its turn.complete ends it, a late call does not revive it).
178      status: a.status === 'waiting' || (a.kind === 'subagent' && isDone(a.status)) ? a.status : 'running',
179      endedAt: a.status === 'waiting' || (a.kind === 'subagent' && isDone(a.status)) ? a.endedAt : null,
180    }
181  })
182
183export const toolFinished = (
184  g: SessionGraph,
185  id: string,
186  tool: string,
187  now = 0,
188  key: string | null = useKey(tool),
189  callId: string | null = null,
190): SessionGraph =>
191  patch(g, id, prev => {
192    const a = heard(prev, now)
193    const calls = a.inFlight ?? []
194    // The call by its id; without one, the latest call of the same tool.
195    let i = callId ? calls.findIndex(c => c.callId === callId) : -1
196    for (let j = calls.length - 1; j >= 0 && i < 0; j--) if (calls[j]!.tool === tool) i = j
197    const rest = i < 0 ? calls : calls.filter((_, j) => j !== i)
198    return {
199      ...withCalls(a, rest),
200      ...(key ? { toolAt: { ...(a.toolAt ?? {}), [key]: now } } : {}),
201      waitingSince: rest.some(c => ASK_TOOLS.has(c.tool)) ? a.waitingSince : null,
202    }
203  })
204
205/** An agent loaded a rule or CLAUDE.md file (InstructionsLoaded): listed among its uses. */
206export const ruleLoaded = (g: SessionGraph, id: string, path: string, now = 0): SessionGraph => {
207  const key = `rule:${path}`
208  return patch(g, id, a => ({ ...heard(a, now), tools: { ...a.tools, [key]: (a.tools[key] ?? 0) + 1 }, toolAt: { ...(a.toolAt ?? {}), [key]: now } }))
209}
210
211/** One thing an agent used, as its card lists it. */
212export type Use = { key: string; kind: UseKind; label: string; calls: number; at: number; isActive: boolean }
213
214/** What an agent used, in first-use order; a use is active while the agent runs it or just did. */
215export const agentUses = (a: AgentNode, now: number): Use[] =>
216  Object.entries(a.tools).map(([key, calls]) => ({ key, ...useOf(key), calls, at: a.toolAt?.[key] ?? 0, isActive: isToolHot(a, key, now) }))
217
218/** True while `a` runs the use `key`, or started or finished it less than TOOL_GLOW_MS ago. */
219export const isToolHot = (a: AgentNode, key: string, now: number) =>
220  a.currentUse === key || now - (a.toolAt?.[key] ?? -Infinity) < TOOL_GLOW_MS
221
222/** The uses of an agent, most used first: `Grep×3 skill commit×1` in the status lines. */
223export const toolSummary = (a: AgentNode) =>
224  Object.entries(a.tools)
225    .sort((x, y) => y[1] - x[1])
226    .map(([key, n]) => {
227      const u = useOf(key)
228      return `${u.kind === 'tool' ? u.label : `${u.kind} ${u.label}`}×${n}`
229    })
230    .join(' ')
231
232/** The `context.breakdown` fields of `$.session.usage` the harness card reads. */
233export type HarnessBreakdown = {
234  memoryFiles?: readonly { path: string }[]
235  mcpTools?: readonly { serverName: string }[]
236  agents?: readonly unknown[]
237  skills?: { totalSkills: number }
238}
239
240/** The harness card from the merged settings (`hooks`) and the context breakdown. */
241export const harnessFrom = (settings: { hooks?: unknown }, breakdown: HarnessBreakdown = {}): Harness => {
242  const hooks: Record<string, number> = {}
243  if (settings.hooks && typeof settings.hooks === 'object') {
244    for (const [event, groups] of Object.entries(settings.hooks)) {
245      if (!Array.isArray(groups)) continue
246      const n = groups.reduce((sum: number, group: { hooks?: unknown }) => sum + (Array.isArray(group?.hooks) ? group.hooks.length : 0), 0)
247      if (n > 0) hooks[event] = n
248    }
249  }
250  return {
251    rules: (breakdown.memoryFiles ?? []).map(f => f.path),
252    hooks,
253    skills: breakdown.skills?.totalSkills ?? 0,
254    mcpServers: [...new Set((breakdown.mcpTools ?? []).map(t => t.serverName))],
255    agents: breakdown.agents?.length ?? 0,
256  }
257}
258
259export const setHarness = (g: SessionGraph, harness: Harness): SessionGraph => ({ ...g, harness })
260
261/** The harness card's lines, one per kind: `hooks 3: PreToolUse 2, Stop 1`. */
262export const harnessLines = (h: Harness): string[] => {
263  const list = (xs: string[]) => (xs.length ? `: ${xs.join(', ')}` : '')
264  const hookTotal = Object.values(h.hooks).reduce((n, k) => n + k, 0)
265  return [
266    `rules ${h.rules.length}${list(h.rules.map(baseName))}`,
267    `hooks ${hookTotal}${list(Object.entries(h.hooks).map(([event, n]) => `${event} ${n}`))}`,
268    `skills ${h.skills}`,
269    `mcp ${h.mcpServers.length}${list(h.mcpServers)}`,
270    `agents ${h.agents}`,
271  ]
272}
273
274export type TurnEnd = { tokens: number; reason: 'answer' | 'aborted' | 'refusal' | 'error'; model?: string }
275
276/**
277 * A turn ended: a subagent is finished (its run is one turn); the main loop and teammates go idle,
278 * and a teammate whose turn failed is marked failed until its next tool call.
279 */
280export const turnEnded = (g: SessionGraph, id: string, t: TurnEnd, now: number): SessionGraph =>
281  patch(withAgent(g, id, now), id, a => {
282    const failed = t.reason === 'error' || t.reason === 'refusal'
283    const status: NodeStatus =
284      a.kind === 'subagent' ? (t.reason === 'answer' ? 'completed' : t.reason === 'aborted' ? 'killed' : 'failed') : a.kind === 'teammate' && failed ? 'failed' : 'idle'
285    return {
286      ...a,
287      status,
288      tokens: a.tokens + t.tokens,
289      model: t.model || a.model,
290      currentTool: null,
291      currentUse: null,
292      inFlight: [],
293      waitingSince: null,
294      endedAt: isDone(status) ? now : a.endedAt,
295      lastEventAt: now,
296    }
297  })
298
299/** Which agent a SendMessage `to` names: an id, a teammate address (`name@team`), or a bare name. */
300export const resolveRecipient = (g: SessionGraph, to: string): string | null => {
301  if (to === MAIN || g.agents[to]) return to
302  for (const id of g.order) {
303    const a = g.agents[id]
304    if (!a) continue
305    if (a.label === to || a.label.split('@')[0] === to) return id
306  }
307  return null
308}
309
310export const messageSent = (g: SessionGraph, from: string, to: string): SessionGraph => {
311  if (!g.agents[from] || !g.agents[to] || from === to) return g
312  const i = g.messages.findIndex(m => m.from === from && m.to === to)
313  const messages =
314    i < 0 ? [...g.messages, { from, to, count: 1 }] : g.messages.map((m, j) => (j === i ? { ...m, count: m.count + 1 } : m))
315  return { ...g, messages }
316}
317
318/** One row of `$.agent.list()`, the fields q-graph reads. */
319export type ListedAgent = {
320  id: string
321  status: NodeStatus
322  type: string
323  description: string
324  parentId?: string
325  name?: string
326  teammateId?: string
327}
328
329export type Transition = { id: string; from: NodeStatus | null; to: NodeStatus }
330
331/**
332 * Takes the engine's view of each agent. Agents it lists that no `agent.spawn` reached (a forked
333 * skill's loop) are added. One it no longer lists keeps what the hooks recorded, until it is also
334 * silent for LOST_MS: then it is `lost`.
335 */
336export const syncAgents = (g: SessionGraph, list: readonly ListedAgent[], now: number): { graph: SessionGraph; transitions: Transition[] } => {
337  let graph = g
338  const transitions: Transition[] = []
339  for (const row of list) {
340    const prev = graph.agents[row.id]
341    if (!prev) {
342      graph = spawnAgent(graph, { id: row.id, parentId: row.parentId, isTeammate: !!row.teammateId, type: row.type, name: row.name, teammateId: row.teammateId, description: row.description }, now)
343    }
344    const cur = graph.agents[row.id]!
345    // A finished subagent stays finished: the list can lag behind its turn.complete.
346    if (cur.status === row.status || (isDone(cur.status) && cur.kind === 'subagent')) continue
347    transitions.push({ id: row.id, from: prev ? cur.status : null, to: row.status })
348    graph = patch(graph, row.id, a => ({
349      ...a,
350      status: row.status,
351      endedAt: isDone(row.status) ? (a.endedAt ?? now) : null,
352      ...(row.status === 'running' || row.status === 'waiting' ? {} : { currentTool: null, currentUse: null, inFlight: [] }),
353    }))
354  }
355  // Workflow-started agents are never listed, so absence alone proves nothing: it takes silence too,
356  // and a call still in flight counts as activity (its end always raises tool.call's finally).
357  if (list.length > 0) {
358    const listed = new Set(list.map(r => r.id))
359    for (const id of graph.order) {
360      const a = graph.agents[id]
361      if (!a || a.kind === 'main' || listed.has(id) || !LIVE.has(a.status)) continue
362      if ((a.inFlight ?? []).length > 0 || now - (a.lastEventAt ?? 0) < LOST_MS) continue
363      graph = patch(graph, id, x => ({ ...x, status: 'lost', endedAt: now, waitingSince: null }))
364    }
365  }
366  return { graph, transitions }
367}
368
369/** Agents other than main that are still going, or finished within RECENT_MS. */
370export const rosterAgents = (g: SessionGraph, now: number): AgentNode[] =>
371  g.order
372    .map(id => g.agents[id])
373    .filter((a): a is AgentNode => !!a && a.kind !== 'main')
374    .filter(a => !(isDone(a.status) || a.status === 'lost') || (a.endedAt !== null && now - a.endedAt < RECENT_MS))
375
376/** True while some agent other than main still runs or waits: the poll and the animation run until it is false. */
377export const hasLiveAgents = (g: SessionGraph) =>
378  g.order.some(id => {
379    const a = g.agents[id]
380    return !!a && a.kind !== 'main' && (a.status === 'running' || a.status === 'waiting' || a.status === 'pending')
381  })
382
383export const formatElapsed = (ms: number) => {
384  const s = Math.max(0, Math.floor(ms / 1000))
385  if (s < 60) return `${s}s`
386  const m = Math.floor(s / 60)
387  if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}`
388  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
389}
390
391export const formatTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n)))
392
393/** The model's family for a card: `claude-haiku-5-5` → `haiku`; an unknown id loses only its prefix and date. */
394export const shortModel = (model: string) =>
395  /\b(opus|sonnet|haiku|fable)\b/i.exec(model)?.[1]?.toLowerCase() ?? model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
396
397export const STATUS_TEXT: Record<NodeStatus, string> = {
398  pending: '· starting',
399  running: 'running',
400  waiting: '⏸ waiting',
401  idle: '○ idle',
402  completed: '✓ done',
403  failed: '✗ failed',
404  killed: '■ stopped',
405  lost: '? lost',
406}
407
408/** One line per agent for `/q-graph status` and for surfaces without a Raster. */
409export const describeAgents = (g: SessionGraph, now: number): string[] => {
410  const depth = (a: AgentNode, seen = new Set<string>()): number => {
411    const parent = a.parentId ? g.agents[a.parentId] : undefined
412    if (!parent || seen.has(a.id)) return 0
413    seen.add(a.id)
414    return depth(parent, seen) + 1
415  }
416  return g.order
417    .map(id => g.agents[id])
418    .filter((a): a is AgentNode => !!a)
419    .map(a => {
420      const status = needsYou(a)
421        ? `⚠ needs you (${askingTool(a)})`
422        : a.status === 'running'
423          ? a.currentTool
424            ? `running ${a.currentTool}`
425            : 'running'
426          : STATUS_TEXT[a.status]
427      const time = a.startedAt > 0 ? formatElapsed((a.endedAt ?? now) - a.startedAt) : '–'
428      const model = a.model ? ` · ${shortModel(a.model)}` : ''
429      const what = a.description ? ` · ${a.description}` : ''
430      const used = a.toolCalls ? ` (${toolSummary(a)})` : ''
431      return `${'  '.repeat(depth(a))}${a.label}${model} · ${status} · ${time} · ${formatTokens(a.tokens)} tok · ${a.toolCalls} tools${used}${what}`
432    })
433}
434
hooks/lib/layout.ts 135 lines
1// Lays the agent tree out left to right in terminal cells: one column per depth, each agent on its
2// own band of rows (its card, then the list of what it used), its first child level with it, and
3// the session's harness card on top. Small enough not to need a layout library.
4
5import type { SessionGraph } from '../../types'
6import { MAIN, harnessLines } from './graph'
7
8/** An agent's card; `uses` rows under it list what the agent used. */
9export type Box = { id: string; x: number; y: number; w: number; h: number; depth: number; uses: number }
10
11/** The harness card: what the session has configured or loaded. */
12export type HarnessBox = { x: number; y: number; w: number; h: number }
13
14/** `hidden`: agents the drawing could not place (too deep, past the room, MAX_COLUMNS or MAX_ROWS, or not reachable from main). */
15export type Layout = { boxes: Box[]; harness: HarnessBox | null; columns: number; rows: number; hidden: number }
16
17/** Rows of a card: its top border, the title row, the status row, its bottom border. */
18export const CARD_H = 4
19const ROW_GAP = 1
20const COL_GAP = 8
21/** The gap between columns shrinks to this before the cards do. */
22const MIN_GAP = 4
23/** Levels deeper than this are not placed; their agents count as hidden. */
24const MAX_DEPTH = 25
25const MIN_W = 18
26const MAX_W = 32
27/** The harness card is wider than an agent card when the pane has room: its lines list names. */
28const HARNESS_W = 44
29/** Columns past the furthest card that message arcs take (raster.ts: ARC_LANES lanes plus the gap). */
30const ARC_ROOM = 8
31/** The widest a Raster takes. */
32export const MAX_COLUMNS = 512
33/** The tallest one Raster takes: a taller drawing is split into stacked tiles (raster.ts splitFrame). */
34export const TILE_ROWS = 256
35/** The tallest drawing, across its tiles: a bound on memory for very large sessions. */
36export const MAX_ROWS = TILE_ROWS * 16
37
38/** Uses listed under one card before the rest fold into a `+N more` row: none fold, every use is listed (see README). */
39export const MAX_USES = Number.POSITIVE_INFINITY
40
41/** Rows the use list under a card takes: one per use up to MAX_USES, then one `+N more` row. */
42export const useRows = (n: number) => (n > MAX_USES ? MAX_USES + 1 : n)
43
44const compute = (g: SessionGraph, room: number): Layout => {
45  const children = new Map<string, string[]>()
46  const parentOf = new Map<string, string>()
47  for (const id of g.order) {
48    const a = g.agents[id]
49    if (!a || id === MAIN) continue
50    const parent = a.parentId && g.agents[a.parentId] ? a.parentId : MAIN
51    parentOf.set(id, parent)
52    const kids = children.get(parent)
53    if (kids) kids.push(id)
54    else children.set(parent, [id])
55  }
56
57  // Borders, the title row, one row per line.
58  const harnessH = g.harness ? harnessLines(g.harness).length + 3 : 0
59  const depthOf = new Map<string, number>()
60  const ys = new Map<string, number>()
61  const placed: string[] = []
62  let cursor = harnessH > 0 ? harnessH + ROW_GAP : 0
63  let maxDepth = 0
64  // Pre-order, iterative: an agent takes the next free row and its first child sits level with it;
65  // the band ends below whichever is lower, its own card and uses or its last descendant's.
66  // Agents past MAX_DEPTH, in a parent cycle or never reached from main are not placed.
67  type Frame = { id: string; y: number; own: number; kids: string[]; next: number }
68  const stack: Frame[] = []
69  const seen = new Set<string>()
70  const enter = (id: string, depth: number) => {
71    seen.add(id)
72    depthOf.set(id, depth)
73    placed.push(id)
74    maxDepth = Math.max(maxDepth, depth)
75    ys.set(id, cursor)
76    const own = CARD_H + useRows(Object.keys(g.agents[id]?.tools ?? {}).length)
77    stack.push({ id, y: cursor, own, kids: depth < MAX_DEPTH ? (children.get(id) ?? []) : [], next: 0 })
78  }
79  enter(MAIN, 0)
80  while (stack.length > 0) {
81    const f = stack[stack.length - 1]!
82    const k = f.kids[f.next++]
83    if (k === undefined) {
84      cursor = Math.max(cursor, f.y + f.own + ROW_GAP)
85      stack.pop()
86    } else if (!seen.has(k)) enter(k, stack.length)
87  }
88
89  // Fit the levels into the room: the gap between columns shrinks first, then the cards; levels
90  // that still do not fit are hidden.
91  const levels = maxDepth + 1
92  const widthAt = (gap: number) => Math.floor((room - gap * maxDepth) / levels)
93  const gap = maxDepth > 0 && widthAt(COL_GAP) < MIN_W ? Math.max(MIN_GAP, Math.min(COL_GAP, Math.floor((room - MIN_W * levels) / maxDepth))) : COL_GAP
94  const w = Math.max(MIN_W, Math.min(MAX_W, widthAt(gap)))
95  const fitLevels = Math.max(1, Math.floor((room + gap) / (w + gap)))
96  const drawn = new Set<string>()
97  const boxes: Box[] = []
98  // Parents first, so an agent under a hidden parent is hidden too.
99  const boxFor = new Map<string, Box>()
100  for (const id of placed) {
101    const y = ys.get(id)!
102    const depth = depthOf.get(id)!
103    const x = depth * (w + gap)
104    if (id !== MAIN && !drawn.has(parentOf.get(id) ?? MAIN)) continue
105    if (depth >= fitLevels || y + CARD_H > MAX_ROWS || x + w > MAX_COLUMNS) continue
106    const uses = Math.min(useRows(Object.keys(g.agents[id]?.tools ?? {}).length), MAX_ROWS - (y + CARD_H))
107    drawn.add(id)
108    boxFor.set(id, { id, x, y, w, h: CARD_H, depth, uses })
109  }
110  for (const id of g.order) {
111    const b = boxFor.get(id)
112    if (b) boxes.push(b)
113  }
114  const hidden = g.order.filter(id => g.agents[id] && !boxFor.has(id)).length
115  const harness = harnessH > 0 ? { x: 0, y: 0, w: Math.min(MAX_COLUMNS, Math.max(w, Math.min(HARNESS_W, room))), h: harnessH } : null
116  // Messages between siblings bow out to the right of their column.
117  const arcRoom = g.messages.length > 0 ? ARC_ROOM : 0
118  const right = [...boxes.map(b => b.x + b.w + arcRoom), ...(harness ? [harness.x + harness.w] : [])]
119  const bottom = [...boxes.map(b => b.y + b.h + b.uses), ...(harness ? [harness.y + harness.h] : [])]
120  const columns = Math.min(MAX_COLUMNS, Math.max(1, ...right))
121  const rows = Math.min(MAX_ROWS, Math.max(1, ...bottom))
122  return { boxes, harness, columns, rows, hidden }
123}
124
125const memo = new WeakMap<SessionGraph, { room: number; layout: Layout }>()
126
127/** The layout of `g` in `room` columns; the same graph and room give the same Layout object. */
128export const layoutGraph = (g: SessionGraph, room: number): Layout => {
129  const hit = memo.get(g)
130  if (hit && hit.room === room) return hit.layout
131  const layout = compute(g, room)
132  memo.set(g, { room, layout })
133  return layout
134}
135
hooks/lib/raster.ts 466 lines
1// Draws a laid-out graph into the cells of a terminal `Raster`: cards with a rounded border in the
2// agent's colour, edges routed at right angles with box-drawing lines (one trunk per parent, a
3// `├──▸` branch per child), a dot that moves along the branch of every running agent, under each
4// card the list of what that agent used (tools, skills, MCP servers, rules; filled while in use),
5// and the session's harness card. Text uses the terminal's own colour, so the graph reads on light
6// and dark terminals alike.
7// Each cell is [codePoint, foreground, background] (RasterProps.cells).
8
9import type { AgentKind, AgentNode, Harness, SessionGraph, UseKind } from '../../types'
10import { STATUS_TEXT, agentUses, formatElapsed, formatTokens, harnessLines, needsYou, shortModel, type Use } from './graph'
11import { MAX_USES, TILE_ROWS, type Box, type HarnessBox, type Layout } from './layout'
12
13/** The terminal's own colour (RasterProps: bit 24 alone). */
14export const DEFAULT = 0x01000000
15
16export const COLORS = {
17  dim: 0x727d8c,
18  edge: 0x5c6878,
19  ok: 0x4cbf7f,
20  warn: 0xc07a10,
21  bad: 0xf0675a,
22  tool: 0x8b5628,
23  ink: 0x15191f,
24} as const
25
26export const KIND_COLOR: Record<AgentKind, number> = { main: 0x3969de, subagent: 0x1f966f, teammate: 0xa72f83 }
27/** One hue per kind of use under a card. */
28export const USE_COLOR: Record<UseKind, number> = { tool: COLORS.tool, skill: 0x9a55d1, mcp: 0x1080a6, rule: 0x3ea313 }
29/** The same hues, quieter: the border of a card that is not running. */
30const KIND_DIM: Record<AgentKind, number> = { main: 0x5071bd, subagent: 0x408c7a, teammate: 0x924e87 }
31
32// Keyed by string so a status the union does not list yet (`lost`) still has a colour.
33const STATUS_COLOR: Record<string, number> = {
34  pending: COLORS.dim,
35  running: KIND_COLOR.subagent,
36  waiting: COLORS.dim,
37  idle: COLORS.dim,
38  completed: COLORS.ok,
39  failed: COLORS.bad,
40  killed: COLORS.dim,
41  lost: COLORS.warn,
42}
43
44const SPINNER = '◐◓◑◒'
45/** Cells a moving dot advances per frame. */
46const DOT_STEP = 1
47
48// Line directions, combined per cell into one box-drawing glyph.
49const U = 1
50const D = 2
51const L = 4
52const R = 8
53const GLYPH: Record<number, number> = {
54  [U | D]: 0x2502, // │
55  [L | R]: 0x2500, // ─
56  [D | R]: 0x256d, // ╭
57  [D | L]: 0x256e, // ╮
58  [U | R]: 0x2570, // ╰
59  [U | L]: 0x256f, // ╯
60  [U | D | R]: 0x251c, // ├
61  [U | D | L]: 0x2524, // ┤
62  [L | R | D]: 0x252c, // ┬
63  [L | R | U]: 0x2534, // ┴
64  [U | D | L | R]: 0x253c, // ┼
65  [U]: 0x2502,
66  [D]: 0x2502,
67  [L]: 0x2500,
68  [R]: 0x2500,
69}
70/** Dashed lines for messages between agents. */
71const DASH_H = 0x2504 // ┄
72const DASH_V = 0x2506 // ┆
73const ARROW = 0x25b8 // ▸
74const ARROW_BACK = 0x25c2 // ◂
75const DOT = 0x25cf // ●
76
77/** Marks, format and unassigned characters, emoji drawn as pictures: none of them is one column wide. */
78const NOT_ONE_COLUMN = /[\p{M}\p{Cf}\p{Cn}\p{Cc}\p{Co}\p{Emoji_Presentation}]/u
79
80/** Left out without a trace: combining marks, variation selectors and the zero-width joiner have no cell of their own. */
81const DROPPED = /[\p{M}\u200d\ufe00-\ufe0f]/u
82
83/** True for a code point a Raster cell takes: printable, one column wide, in the BMP. */
84const isCellChar = (cp: number) => {
85  const inRange =
86    (cp >= 0x20 && cp < 0x7f) ||
87    (cp > 0xad && cp < 0x300) ||
88    (cp >= 0xa0 && cp < 0xad) ||
89    (cp >= 0x370 && cp < 0x590) || // Greek, Cyrillic, Armenian; right-to-left scripts and Indic marks left out
90    (cp >= 0x1e00 && cp < 0x2000) ||
91    (cp >= 0x2010 && cp < 0x2028) ||
92    (cp >= 0x2030 && cp < 0x2060) ||
93    (cp >= 0x2070 && cp < 0x2329) ||
94    (cp > 0x232a && cp < 0x2c00)
95  return inRange && !NOT_ONE_COLUMN.test(String.fromCharCode(cp))
96}
97
98export type Frame = { columns: number; rows: number; cells: string }
99
100type Cell = readonly [number, number]
101
102/** The row an edge meets a card on: its title row. */
103const edgeRow = (b: { y: number }) => b.y + 1
104
105/** Lanes the message arcs spread over, right of the furthest card; further messages share a lane. */
106const ARC_LANES = 6
107
108type Painter = {
109  put: (x: number, y: number, ch: number, f: number, b?: number) => void
110  text: (x: number, y: number, s: string, f: number, max: number, b?: number) => number
111}
112
113const painter = (columns: number, rows: number, glyph: Uint32Array, fg: Uint32Array, bg: Uint32Array): Painter => {
114  const put: Painter['put'] = (x, y, ch, f, b) => {
115    if (x < 0 || y < 0 || x >= columns || y >= rows) return
116    const i = y * columns + x
117    glyph[i] = ch
118    fg[i] = f
119    if (b !== undefined) bg[i] = b
120  }
121  // Text longer than `max` ends in `…`; what a cell cannot hold becomes one `?` per run.
122  const text: Painter['text'] = (x, y, s, f, max, b) => {
123    if (max <= 0) return 0
124    let cps: number[] = []
125    let replaced = false
126    for (const ch of s.normalize('NFC')) {
127      if (DROPPED.test(ch)) continue
128      const cp = ch.codePointAt(0) ?? 0x3f
129      if (isCellChar(cp)) {
130        cps.push(cp)
131        replaced = false
132      } else if (!replaced) {
133        cps.push(0x3f)
134        replaced = true
135      }
136    }
137    if (cps.length > max) cps = [...cps.slice(0, max - 1), 0x2026]
138    cps.forEach((cp, n) => put(x + n, y, cp, f, b))
139    return cps.length
140  }
141  return { put, text }
142}
143
144/** The part of a frame that only changes with the graph's shape and statuses: lines, arrows, dot paths. */
145type StaticLayer = { key: string; glyph: Uint32Array; fg: Uint32Array; dirs: Uint8Array; moving: { path: Cell[]; color: number }[] }
146
147let staticLayer: StaticLayer | null = null
148
149/** What the static layer depends on: placement, parents, which agents run, and the message pairs. */
150const staticKey = (g: SessionGraph, layout: Layout) => {
151  let key = `${layout.columns}x${layout.rows}`
152  for (const b of layout.boxes) {
153    const a = g.agents[b.id]
154    key += `\n${b.id},${b.x},${b.y},${b.w},${a?.parentId ?? ''},${a?.status === 'running' ? a.kind : ''}`
155  }
156  for (const m of g.messages) key += `\n${m.from}>${m.to}`
157  return key
158}
159
160const buildStatic = (g: SessionGraph, layout: Layout, key: string): StaticLayer => {
161  const { columns, rows } = layout
162  const size = columns * rows
163  const glyph = new Uint32Array(size).fill(0x20)
164  const fg = new Uint32Array(size).fill(DEFAULT)
165  // The line layer: directions per cell, its colour, and the rank that decides whose colour wins.
166  const dirs = new Uint8Array(size)
167  const lineColor = new Uint32Array(size)
168  const lineRank = new Uint8Array(size)
169  const dashed = new Uint8Array(size)
170
171  const inside = (x: number, y: number) => x >= 0 && y >= 0 && x < columns && y < rows
172  const { put } = painter(columns, rows, glyph, fg, new Uint32Array(0))
173  const mark = (x: number, y: number, d: number, color: number, rank: number) => {
174    if (!inside(x, y)) return
175    const i = y * columns + x
176    dirs[i]! |= d
177    if (rank >= lineRank[i]!) {
178      lineRank[i] = rank
179      lineColor[i] = color
180    }
181  }
182  /** A horizontal run of line cells, joined to its neighbours; returns the cells left to right. */
183  const hline = (x0: number, x1: number, y: number, color: number, rank: number): Cell[] => {
184    const cells: Cell[] = []
185    const [a, b] = x0 <= x1 ? [x0, x1] : [x1, x0]
186    for (let x = a; x <= b; x++) {
187      mark(x, y, (x > a ? L : 0) | (x < b ? R : 0), color, rank)
188      cells.push([x, y])
189    }
190    return x0 <= x1 ? cells : cells.reverse()
191  }
192  const vline = (x: number, y0: number, y1: number, color: number, rank: number): Cell[] => {
193    const cells: Cell[] = []
194    const [a, b] = y0 <= y1 ? [y0, y1] : [y1, y0]
195    for (let y = a; y <= b; y++) {
196      mark(x, y, (y > a ? U : 0) | (y < b ? D : 0), color, rank)
197      cells.push([x, y])
198    }
199    return y0 <= y1 ? cells : cells.reverse()
200  }
201  /** Joins the corner cell of two runs, so ─ meeting │ becomes ╮, ├ or ┼. */
202  const joinAt = (x: number, y: number, d: number, color: number, rank: number) => mark(x, y, d, color, rank)
203
204  const boxOf = new Map(layout.boxes.map(b => [b.id, b]))
205  const moving: { path: Cell[]; color: number }[] = []
206
207  // ---- tree edges: parent ─┬─ trunk ├──▸ child ------------------------------------------------
208  const children = new Map<string, Box[]>()
209  for (const b of layout.boxes) {
210    const a = g.agents[b.id]
211    if (!a?.parentId || !boxOf.has(a.parentId)) continue
212    children.set(a.parentId, [...(children.get(a.parentId) ?? []), b])
213  }
214  for (const [parentId, kids] of children) {
215    const p = boxOf.get(parentId)!
216    const py = edgeRow(p)
217    const right = p.x + p.w
218    // The trunk sits midway between the parent's column and its children's.
219    const childX = Math.min(...kids.map(k => k.x))
220    const trunkX = right + Math.max(1, Math.floor((childX - right - 1) / 2))
221    const ys = kids.map(edgeRow)
222    const top = Math.min(py, ...ys)
223    const bottom = Math.max(py, ...ys)
224    const base = COLORS.edge
225    hline(right, trunkX, py, base, 1)
226    vline(trunkX, top, bottom, base, 1)
227    joinAt(trunkX, py, L, base, 1)
228    for (const k of kids) {
229      const a = g.agents[k.id]!
230      const ky = edgeRow(k)
231      const isHot = a.status === 'running'
232      const color = isHot ? KIND_COLOR[a.kind] : base
233      const rank = isHot ? 3 : 1
234      // A running child lights its own way back to the parent.
235      const stub = hline(right, trunkX - 1, py, isHot ? color : base, isHot ? 2 : 1)
236      const trunk = vline(trunkX, py, ky, isHot ? color : base, isHot ? 2 : 1)
237      const branch = hline(trunkX, k.x - 2, ky, color, rank)
238      joinAt(trunkX, ky, R, color, rank)
239      put(k.x - 1, ky, ARROW, color)
240      if (isHot) moving.push({ path: [...stub, ...trunk.slice(1), ...branch.slice(1)], color })
241    }
242  }
243
244  // ---- messages between agents: a dashed loop in its own column right of the furthest card ----------
245  const maxRight = Math.max(0, ...layout.boxes.map(b => b.x + b.w))
246  let lane = 0
247  for (const m of g.messages) {
248    const from = boxOf.get(m.from)
249    const to = boxOf.get(m.to)
250    // Between a parent and its child the tree edge already joins them.
251    if (!from || !to || g.agents[m.from]?.parentId === m.to || g.agents[m.to]?.parentId === m.from) continue
252    const x = maxRight + 1 + (lane++ % ARC_LANES)
253    const fy = from.y + 2
254    const ty = to.y + 2
255    const color = KIND_COLOR.teammate
256    for (const [cx, cy] of [...hline(from.x + from.w, x, fy, color, 1), ...vline(x, fy, ty, color, 1), ...hline(x, to.x + to.w + 1, ty, color, 1)]) {
257      if (inside(cx, cy)) dashed[cy * columns + cx] = 1
258    }
259    joinAt(x, fy, L, color, 1)
260    joinAt(x, ty, L, color, 1)
261    put(to.x + to.w, ty, ARROW_BACK, color)
262  }
263
264  for (let i = 0; i < size; i++) {
265    const d = dirs[i]!
266    if (!d || glyph[i] !== 0x20) continue
267    const straightH = d === (L | R) || d === L || d === R
268    const straightV = d === (U | D) || d === U || d === D
269    glyph[i] = dashed[i] && straightH ? DASH_H : dashed[i] && straightV ? DASH_V : (GLYPH[d] ?? 0x253c)
270    fg[i] = lineColor[i]!
271  }
272
273  return { key, glyph, fg, dirs, moving }
274}
275
276export const renderFrame = (g: SessionGraph, layout: Layout, now: number, phase: number): Frame => {
277  const { columns, rows } = layout
278  const size = columns * rows
279  const key = staticKey(g, layout)
280  if (staticLayer?.key !== key) staticLayer = buildStatic(g, layout, key)
281  const { dirs, moving } = staticLayer
282  // Per tick only what moves is drawn over a copy of the static layer: cards (time, spinner, glow),
283  // uses and the dots.
284  const glyph = staticLayer.glyph.slice()
285  const fg = staticLayer.fg.slice()
286  const bg = new Uint32Array(size).fill(DEFAULT)
287  const { put, text } = painter(columns, rows, glyph, fg, bg)
288  const inside = (x: number, y: number) => x >= 0 && y >= 0 && x < columns && y < rows
289
290  // ---- cards, their uses and the harness card over the lines ---------------------------------------
291  for (const b of layout.boxes) {
292    const a = g.agents[b.id]
293    if (!a) continue
294    drawCard(a, b, now, phase, put, text)
295    if (b.uses > 0) drawUses(a, b, now, put, text)
296  }
297  if (layout.harness && g.harness) drawHarness(g.harness, layout.harness, put, text)
298
299  // ---- a dot on each lit path -----------------------------------------------------------------------
300  moving.forEach((m, k) => {
301    if (m.path.length < 2) return
302    const at = m.path[(phase * DOT_STEP + k * 3) % m.path.length]!
303    const i = at[1] * columns + at[0]
304    if (inside(at[0], at[1]) && dirs[i] && (glyph[i] === GLYPH[dirs[i]!] || glyph[i] === DASH_H || glyph[i] === DASH_V)) put(at[0], at[1], DOT, m.color)
305  })
306
307  const words = new Uint32Array(size * 3)
308  for (let i = 0; i < size; i++) {
309    words[i * 3] = glyph[i]!
310    words[i * 3 + 1] = fg[i]!
311    words[i * 3 + 2] = bg[i]!
312  }
313  return { columns, rows, cells: toBase64(new Uint8Array(words.buffer)) }
314}
315
316/**
317 * Cuts a frame into Rasters of at most `tileRows` rows each, stacked top to bottom. A row is
318 * `columns` triplets of 12 bytes, a multiple of 3, so each row is exactly `columns * 16` base64
319 * characters and the encoded string splits at row boundaries without re-encoding.
320 */
321export const splitFrame = (frame: Frame, tileRows = TILE_ROWS): Frame[] => {
322  if (frame.rows <= tileRows) return [frame]
323  const rowChars = frame.columns * 16
324  const tiles: Frame[] = []
325  for (let top = 0; top < frame.rows; top += tileRows) {
326    const rows = Math.min(tileRows, frame.rows - top)
327    tiles.push({ columns: frame.columns, rows, cells: frame.cells.slice(top * rowChars, (top + rows) * rowChars) })
328  }
329  return tiles
330}
331
332const B64 = new Uint8Array('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split('').map(c => c.charCodeAt(0)))
333/** Characters per String.fromCharCode call: below the engines' argument limit. */
334const CHUNK = 8192
335
336/** Standard padded base64, the encoding RasterProps.cells takes. */
337export const toBase64 = (bytes: Uint8Array) => {
338  const out = new Uint8Array(Math.ceil(bytes.length / 3) * 4)
339  const full = bytes.length - (bytes.length % 3)
340  let o = 0
341  for (let i = 0; i < full; i += 3) {
342    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
343    out[o++] = B64[n >> 18]!
344    out[o++] = B64[(n >> 12) & 63]!
345    out[o++] = B64[(n >> 6) & 63]!
346    out[o++] = B64[n & 63]!
347  }
348  const rest = bytes.length - full
349  if (rest > 0) {
350    const n = (bytes[full]! << 16) | ((rest > 1 ? bytes[full + 1]! : 0) << 8)
351    out[o++] = B64[n >> 18]!
352    out[o++] = B64[(n >> 12) & 63]!
353    out[o++] = rest > 1 ? B64[(n >> 6) & 63]! : 0x3d
354    out[o++] = 0x3d
355  }
356  let str = ''
357  for (let i = 0; i < out.length; i += CHUNK) str += String.fromCharCode.apply(null, out.subarray(i, i + CHUNK) as unknown as number[])
358  return str
359}
360
361type Put = (x: number, y: number, ch: number, f: number, b?: number) => void
362type Text = (x: number, y: number, s: string, f: number, max: number, b?: number) => number
363
364/** A rounded frame of `w` × `h` cells. */
365const frame = (x: number, y: number, w: number, h: number, color: number, put: Put) => {
366  put(x, y, 0x256d, color) // ╭
367  put(x + w - 1, y, 0x256e, color) // ╮
368  put(x, y + h - 1, 0x2570, color) // ╰
369  put(x + w - 1, y + h - 1, 0x256f, color) // ╯
370  for (let i = x + 1; i < x + w - 1; i++) {
371    put(i, y, 0x2500, color)
372    put(i, y + h - 1, 0x2500, color)
373  }
374  for (let j = y + 1; j < y + h - 1; j++) {
375    put(x, j, 0x2502, color)
376    put(x + w - 1, j, 0x2502, color)
377    for (let i = x + 1; i < x + w - 1; i++) put(i, j, 0x20, DEFAULT)
378  }
379}
380
381/** How a use reads under its card: `Bash ×17`, `skill commit`, `mcp context7 ×2`, `rule CLAUDE.md`. */
382export const useLabel = (u: Use) => useParts(u).join('')
383
384/** A use's name and its `×N` suffix: a long name is cut before the suffix, never the suffix itself. */
385const useParts = (u: Use): [string, string] => {
386  const count = u.calls > 1 || u.kind === 'tool' || u.kind === 'mcp' ? ` ×${u.calls}` : ''
387  return [`${u.kind === 'tool' ? '' : `${u.kind} `}${u.label}`, u.kind === 'rule' ? '' : count]
388}
389
390/** The list under a card: one `├▸` row per use, in first-use order, filled while the agent runs it. */
391const drawUses = (a: AgentNode, b: Box, now: number, put: Put, text: Text) => {
392  // Beyond MAX_USES the rest fold into one row; the bottom of the drawing (MAX_ROWS) can cut the list short too.
393  const all = agentUses(a, now)
394  const rows: (Use | number)[] = all.length > MAX_USES ? [...all.slice(0, MAX_USES), all.length - MAX_USES] : all
395  const shown = rows.slice(0, b.uses)
396  const spine = cardBorder(a)
397  const x = b.x + 2
398  const room = Math.max(1, b.x + b.w - (x + 3))
399  // The list hangs from the card's bottom border.
400  put(x, b.y + b.h - 1, 0x252c, spine) // ┬
401  shown.forEach((r, i) => {
402    const y = b.y + b.h + i
403    const isLast = i === shown.length - 1
404    put(x, y, isLast ? 0x2570 : 0x251c, typeof r === 'number' || !r.isActive ? spine : USE_COLOR[r.kind]) // ╰ ├
405    if (typeof r === 'number') {
406      put(x + 1, y, ARROW, COLORS.dim)
407      text(x + 3, y, `+${r} more`, COLORS.dim, room)
408      return
409    }
410    const color = USE_COLOR[r.kind]
411    put(x + 1, y, ARROW, color)
412    const lit = r.isActive
413    const avail = room - (lit ? 2 : 0)
414    const [name, suffix] = useParts(r)
415    const label = name.length + suffix.length > avail && avail > suffix.length + 1 ? `${name.slice(0, avail - suffix.length - 1)}…${suffix}` : name + suffix
416    text(x + 3, y, lit ? ` ${label} ` : label, lit ? COLORS.ink : color, room, lit ? color : undefined)
417  })
418}
419
420/** The harness card: one line per kind, its name in that kind's colour. */
421const drawHarness = (h: Harness, box: HarnessBox, put: Put, text: Text) => {
422  frame(box.x, box.y, box.w, box.h, COLORS.dim, put)
423  const inner = box.w - 4
424  const x = box.x + 2
425  const used = text(x, box.y + 1, 'harness', DEFAULT, inner)
426  text(x + used + 1, box.y + 1, 'configured · loaded', COLORS.dim, inner - used - 1)
427  const head: Record<string, number> = { rules: USE_COLOR.rule, hooks: COLORS.warn, skills: USE_COLOR.skill, mcp: USE_COLOR.mcp, agents: KIND_COLOR.subagent }
428  harnessLines(h).forEach((line, i) => {
429    const name = line.split(' ')[0]!
430    const n = text(x, box.y + 2 + i, name, head[name] ?? DEFAULT, inner)
431    text(x + n, box.y + 2 + i, line.slice(name.length), DEFAULT, inner - n)
432  })
433}
434
435/** A card's border: amber while it asks the person, its kind's colour while it runs, quieter after. */
436const cardBorder = (a: AgentNode) => (needsYou(a) ? COLORS.warn : a.status === 'running' ? KIND_COLOR[a.kind] : KIND_DIM[a.kind])
437
438const drawCard = (a: AgentNode, b: Box, now: number, phase: number, put: Put, text: Text) => {
439  const asking = needsYou(a)
440  const border = cardBorder(a)
441  frame(b.x, b.y, b.w, b.h, border, put)
442
443  const inner = b.w - 4
444  const x = b.x + 2
445  // Title row: label, model, and the time on the right when it fits.
446  const time = a.startedAt > 0 ? formatElapsed((a.endedAt ?? now) - a.startedAt) : ''
447  const labelMax = Math.max(4, inner - (time ? time.length + 1 : 0))
448  let used = text(x, b.y + 1, a.label, a.status === 'pending' ? COLORS.dim : DEFAULT, labelMax)
449  const model = shortModel(a.model)
450  // The model is shown whole or not at all.
451  if (model && used + 1 + model.length <= labelMax) used += 1 + text(x + used + 1, b.y + 1, model, COLORS.dim, model.length)
452  if (time && used + 1 + time.length <= inner) text(x + inner - time.length, b.y + 1, time, COLORS.dim, time.length)
453
454  // Status row: what it does now, and its tokens on the right.
455  const status = asking
456    ? '! needs you'
457    : a.status === 'running'
458      ? `${SPINNER[phase % SPINNER.length]} ${a.currentTool ?? (a.kind === 'main' ? 'working' : 'running')}`
459      : (STATUS_TEXT as Record<string, string | undefined>)[a.status] ?? `? ${a.status}`
460  const color = asking ? COLORS.warn : a.status === 'running' ? KIND_COLOR[a.kind] : (STATUS_COLOR[a.status] ?? COLORS.dim)
461  const tokens = a.tokens ? formatTokens(a.tokens) : ''
462  const statusMax = Math.max(4, inner - (tokens ? tokens.length + 1 : 0))
463  text(x, b.y + 2, status, color, statusMax)
464  if (tokens && inner >= statusMax + tokens.length) text(x + inner - tokens.length, b.y + 2, tokens, COLORS.dim, tokens.length)
465}
466
hooks/lib/settings.ts 45 lines
1// Settings and the /q-graph usage text. Pure, so everything is easy to test.
2
3import type { GraphSettings } from '../../types'
4
5export const DEFAULT_SETTINGS: GraphSettings = { band: true, notify: true }
6
7const KEYS = ['band', 'notify'] as const
8
9/** Takes only the current settings from a stored value, so unknown keys are ignored and bad values defaulted. */
10export const pickSettings = (saved: unknown): GraphSettings => {
11  const s = (saved && typeof saved === 'object' ? saved : {}) as Record<string, unknown>
12  const next = { ...DEFAULT_SETTINGS }
13  for (const k of KEYS) if (typeof s[k] === 'boolean') next[k] = s[k] as boolean
14  return next
15}
16
17/** Parses `<key> on|off` into a settings patch, or an error message. */
18export const parseToggle = (key: string, value: string): Partial<GraphSettings> | string => {
19  if (!(KEYS as readonly string[]).includes(key)) return `Unknown setting "${key}". Settings: ${KEYS.join(', ')}.`
20  if (!['on', 'off'].includes(value)) return `${key} needs on or off.`
21  return { [key]: value === 'on' }
22}
23
24/** Arguments each subcommand takes beside its own; the toggles take one value, which `parseToggle` checks. */
25const FLAGS: Record<string, readonly string[]> = { open: ['--web'], web: ['stop'], close: [], status: [], config: [], help: [] }
26
27/** The first argument `/q-graph <sub>` does not take, or undefined when all are fine. */
28export const unknownArg = (sub: string, rest: readonly string[]): string | undefined =>
29  sub === 'band' || sub === 'notify' ? rest[1] : sub in FLAGS ? rest.find(r => !FLAGS[sub]!.includes(r)) : undefined
30
31export const showSettings = (cfg: GraphSettings) => KEYS.map(k => `  ${k.padEnd(8)} ${cfg[k] ? 'on' : 'off'}`).join('\n')
32
33export const USAGE = [
34  'Usage: /q-graph [subcommand]',
35  '  (none) | open            open the graph pane',
36  '  open --web | web         also start the live browser viewer and print its link',
37  '  web stop                 stop the live browser viewer',
38  '  close                    close it',
39  '  status                   one line per agent of this session',
40  '  band on|off              the roster band above the prompt',
41  '  notify on|off            a notification when an agent waits for you or fails',
42  '  config                   show the settings',
43  '  help                     this text',
44].join('\n')
45
types/index.d.ts 110 lines
1export type AgentKind = 'main' | 'subagent' | 'teammate'
2
3/**
4 * Where an agent's loop stands: `AgentStatus` from `$.agent.list()`, kept by q-graph after the engine
5 * drops the row, plus `lost`: recorded as live, but the engine no longer lists it and no hook has
6 * named it for a while. Any later hook event for the agent makes it `running` again.
7 */
8export type NodeStatus = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed' | 'lost'
9
10/** A tool call an agent has started and not yet finished. */
11export type InFlightCall = {
12  /** The `tool_use_id` of the call; null when the event carried none. */
13  callId: string | null
14  tool: string
15  /** The use key the call is filed under; null for a call that starts an agent. */
16  use: string | null
17}
18
19export type AgentNode = {
20  /** `main` for the main loop, else the id `agent.spawn` answered (the one `$.agent.list()` and every event's `agentId` use). */
21  id: string
22  /** The agent whose loop spawned it; null for the main loop. */
23  parentId: string | null
24  kind: AgentKind
25  /** The agent type (`Explore`, `general-purpose`, `teammate`, ...). */
26  type: string
27  /** What the graph and the band call it: a teammate's `name@team`, else its name or type. */
28  label: string
29  model: string
30  /** The Agent call's own few words on the task. */
31  description: string
32  status: NodeStatus
33  startedAt: number
34  endedAt: number | null
35  /** When it started asking the person (AskUserQuestion, ExitPlanMode); null while it is not. */
36  waitingSince: number | null
37  /**
38   * What its turns cost: input, output and cache writes. Cache reads are left out: every request
39   * re-reads the cached prefix, so counting them would multiply the figure by the number of requests.
40   */
41  tokens: number
42  toolCalls: number
43  /**
44   * What it used, by use key, in first-use order: a tool name (`Bash`), a skill (`skill:commit`), an
45   * MCP server (`mcp:context7`) or a rule file it loaded (`rule:/repo/.claude/rules/x.md`).
46   */
47  tools: Record<string, number>
48  /** The tool it is running now (the latest call still in flight), if any. */
49  currentTool: string | null
50  /** The use key of the call it is running now, if any. */
51  currentUse: string | null
52  /** Every call it has started and not finished, oldest first: an agent can run several at once. */
53  inFlight: InFlightCall[]
54  /** When a hook last named this agent; with the engine's list it tells a dead agent from a quiet one. */
55  lastEventAt: number
56  /** When it last started or finished each use: the graph lights the use for a moment after a call. */
57  toolAt: Record<string, number>
58}
59
60/** What a use is: the graph colours and labels each kind on its own. */
61export type UseKind = 'tool' | 'skill' | 'mcp' | 'rule'
62
63/** What the session's harness holds: configured or loaded for the session, not used by one agent. */
64export type Harness = {
65  /** The memory files the context carries (CLAUDE.md, rules, auto-memory), by path. */
66  rules: string[]
67  /** Settings hooks per event (`PreToolUse` → 2). Hooks modules of plugins are not listed. */
68  hooks: Record<string, number>
69  /** Skills the session lists. */
70  skills: number
71  /** MCP servers whose tools the session has. */
72  mcpServers: string[]
73  /** Custom agents the Agent tool describes. */
74  agents: number
75}
76
77/** Messages sent between two agents (SendMessage), counted per direction. */
78export type MessageEdge = { from: string; to: string; count: number }
79
80export type SessionGraph = {
81  agents: Record<string, AgentNode>
82  /** Agent ids in the order they appeared; `main` first. */
83  order: string[]
84  messages: MessageEdge[]
85  /** The harness card; null until the first read of the settings and the context. */
86  harness: Harness | null
87}
88
89export type GraphSettings = {
90  /** Draw the roster band above the prompt while agents run. */
91  band: boolean
92  /** Send a native notification when an agent waits for the person or fails. */
93  notify: boolean
94}
95
96declare module 'claude-code' {
97  interface PluginState {
98    'q-graph': {
99      /** Every agent of this session and the messages between them, recorded from the hooks. */
100      graph: SessionGraph
101      /** Loaded from `$.store` at session start; the only value kept across sessions. */
102      settings: GraphSettings
103      /** The live browser viewer's address while its process runs (`/q-graph web`); null otherwise. */
104      webUrl: string | null
105      /** The band lists every agent on wrapped rows (its `+N more` was pressed); false keeps it to one row. */
106      bandAll: boolean
107    }
108  }
109}
110