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…

<img src="../../docs/images/icons/q-graph.svg" width="72" alt="" align="right">
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)
| Feature | What you see | Use it when | ||
|---|---|---|---|---|
| Roster band | A 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 alert | A 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 agent | Under 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 card | On 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 **All | Working** switch next to Graph | Timeline (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.
| Command | What it does | |
|---|---|---|
/q-graph or /q-graph open | Open the graph pane | |
/q-graph web or /q-graph open --web | Start the live browser viewer and print its link (the same link while it runs) | |
/q-graph web stop | Stop the viewer | |
/q-graph close | Close it | |
/q-graph status | One 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 config | Show the settings | |
/q-graph help | Usage |
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.
| Hook or call | What q-graph records |
|---|---|
agent.spawn | A 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 prompt | The 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.send | Messages between agents (SendMessage) |
prompt.submit | The main loop starts working |
$.agent.list(), once a second while an agent runs, waits or is starting | Statuses 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. |
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.
Host is not 127.0.0.1:<port> or localhost:<port> gets 403, which stops DNS rebinding.X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and Cache-Control: no-store. At most 16 tabs can be connected at once.transcript is used only when it is a .jsonl file under ~/.claude/projects./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).integrity hash, so bumping a library version means recomputing it.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).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.https: links.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:
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.
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.
| Node | The 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 use | Its owner agent, the call count and every call of that use |
| Rule | The path (repo-relative or ~/…, the full path on hover) and the agent that loaded it |
| Harness | The 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:
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.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.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).
— in the browser viewer, blank on the pane card, 0 in /q-graph status).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.unknown) that the spawn fills in./q-graph notify off (the toast stays).$.agent.list(); q-graph only sees what its hooks see of them.running or idle, not its tool calls.?, 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.+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.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.See the design artifact for mockups.
| Version | Adds |
|---|---|
| 0.2 | Team view with dagre layout, a time slider in the browser viewer, Mermaid export, an SVG + SMIL pane for the desktop app |
| 0.3 | Decision nodes from AskUserQuestion and ExitPlanMode written to docs/adr/ |
| 0.4 | Draft decisions extracted by Haiku, decisions fed back through prompt.compose, cost per agent type, pixel images for kitty and Ghostty |
/plugin install q-graph --marketplace phuquocchamp/q-harness
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:
| File | Holds |
|---|---|
graph.ts | The 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.ts | The 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.ts | Draws 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.ts | Defaults, /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).
hooks/register.tsx 696 lines1// 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
696hooks/lib/graph.ts 434 lines1// 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}
434hooks/lib/layout.ts 135 lines1// 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}
135hooks/lib/raster.ts 466 lines1// 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}
466hooks/lib/settings.ts 45 lines1// 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')
45types/index.d.ts 110 lines1export 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