SLOPSHOPPER

agent-flow

A live tree of the session's subagents and teammates in a pane: status, current tool, time, calls and tokens

newpaneguardcommandtimeragents
v0.1.0no licenseupdated 2026-10-06diegorv/claude-mods-diegorv/plugins/agent-flow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-flow
│ ┃ Agent flow ✕ › fix the failing auth test and add an audit log call │ ┃ 0 running · 0 done · 0 waiting │ ┃ ○ main · idle ⏺ Read(src/auth.ts) │ ┃ No subagent or teammate yet. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /flow │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent flow
0 running · 0 done · 0 waiting ○ main · idle No subagent or teammate yet.
README

claude-mods-diegorv

Claude Code mods, packaged as a plugin marketplace. A mod is a plugin whose hooks module (TypeScript, here) runs inside the session. Mods need Claude Code 2.1.287 or later and are on by default.

PluginWhat it does
timeThe time you sent each message, drawn above it
gh-ci-statusGitHub Actions runs of the session's repo, pinned above the prompt, with links to the PR and the run
activity-logEvery event of the session, tool calls and their full results included, appended to a JSONL file per session
agent-flowA live tree of the session's subagents and teammates in a pane: status, current tool, time, calls and tokens

time

The time above a message is the one of its first drawing, which is when you sent it, and it is kept per message, so a resize or a redraw does not move it. A resumed session, or a reload of the plugin, draws the old messages with the current time.

gh-ci-status

⚙ owner/repo · Actions · 1 running · 1 finished
#167  ◐ Running    PR       0m37s  chore(ci): smoke-test PR
main  ● Success    Deploy   2m15s  Release 1.4.0
  • Finds the repo the way gh does (gh repo view, the default remote), so gh must be on your PATH and logged in. Without a GitHub remote it stays quiet. The check runs at session start; if it fails there, no network or an expired token, it runs again at the next command that wakes the band (below), so after gh auth login, or once the network is back, a push is enough. Each failure goes to the debug log (claude --debug). While gh fails, the header says so (gh error for 2m05s) and keeps the last rows.
  • Polls gh run list every 60 s, and every 15 s while a run is in flight or for 6 minutes after a push. A git push, git subtree push, gh pr merge, gh workflow run or gh run rerun in Bash wakes it.
  • #N links to the PR (matched by branch through gh pr list, or from refs/pull/N/head); the workflow name (on a narrow band, the status) links to the run.
  • Shows runs from a push, a pull request, a manual dispatch, the merge queue, a release, or a workflow another one started, and leaves cron and issue bots out. Toasts when a run starts and when runs finish: one per run that failed or needs you, one for those that passed; a cancelled or skipped run shows only on the band. A finished run stays for 5 minutes; one that failed, timed out or needs you stays until a newer run of the same workflow, branch and trigger shows up, for 30 minutes at most.
  • details on the band's header opens a pane with every run of the last list, and under each failed one its failed jobs and steps, each linking to the job: ctrl+x tab to focus the band, then d; d or Esc closes it. A mouse click reaches it only in fullscreen.
  • /gh-ci opens or closes the same pane, band or not; before the repo is found it looks again and says so.

activity-log

Writes everything the session does to ~/.claude/activity-log/<date>-<sessionId>.jsonl, one JSON object per line. The date is the local date of the session's first line; after a /clear the lines go to a new file, under the new session id.

  • Each event is two lines with the same seq: a start line with the event's input (and origin, who raised it), written before the event runs, then an end line with its result and durationMs, or an error line. Tool calls, the rows the conversation keeps, the model's requests, prompts, commands, agents and the $ calls of other plugins are all there.
  • The content is whole, nothing cut: a tool's full input and result, every message. An event that runs in a subagent has its agentId on the start line. The model's response and a plugin's spawned process stream; their end line lists the chunks, which pass through unchanged as they come.
  • The files hold everything the session saw, secrets included. The folder is made 700 and each file 600, but nothing is redacted: delete what you do not want kept.
  • Left out: ui.render and ui.resolve, which fire for each component on every redraw; prompt.edit, which fires on every keystroke with the draft so far (prompt.submit keeps the text sent); and this plugin's own calls. Telemetry is only the collector stream of telemetry.log; the anthropic stream, and telemetry.mark with it, is closed to installed plugins.
  • It is large: about 2 MB for a one-line prompt, mostly tool.describe, command.describe and the $ calls of other plugins.
  • Lines are written about once a second and at session end. After a hot reload of the plugin, seq starts again at 0 in the same file, and the last second of lines may be lost.

Reading it with jq:

# tool calls, with input and result side by side
jq -s -c 'map(select(.event == "tool.call")) | group_by(.seq)[]
  | {tool: .[0].input.tool, agentId: .[0].agentId, input: .[0].input, result: .[1].result}' FILE

# one subagent's lines (its ids: jq -r '.agentId // empty' FILE | sort -u)
jq -s -c --arg id AGENT_ID 'map(select(.agentId == $id) | .seq) as $seqs
  | .[] | select(.seq | IN($seqs[]))' FILE

# how many of each event
jq -r 'select(.phase == "start") | .event' FILE | sort | uniq -c | sort -rn

agent-flow

2 running · 1 done · 1 waiting
● main · running 1m24s · Agent 1m23s
  ● general-purpose: find the auth flow · running 1m23s · Grep 4s · 7 calls
    ✓ Explore: read the session store · completed 36s · 12 calls · 20.4k tok
  ● general-purpose: run the test suite · running 1m22s · waiting for approval: Bash · 3 calls
  • /flow opens a pane with the main loop and every subagent and teammate under the loop that spawned it; /flow again closes it. The pane does not take the keyboard. Terminal only.
  • A row: status glyph, type and name (or the task's description), status and elapsed time, the oldest tool call in flight and how long it has run, the calls ended, and once the agent ended its tokens. The header counts the agents running, those done, and the loops waiting for approval.
  • A row stands out (bold, colored) while its loop waits for approval, while a tool call has run over 30 s (an Agent call, which lasts as long as its child, does not count), or while a running agent has had no event for 2 minutes. Ended rows are dimmed.
  • The tree comes from the engine's events (agent.spawn, tool.call, turn.start, turn.complete, and classic.PermissionRequest when no settings hook decided it), checked against $.agent.list() every 2 s while something runs and the pane is open; the list has the last word on status. An agent the list named once and then left out is marked gone.
  • Tokens are uncached input plus output, summed over the agent's turns; cache reads and writes are left out.
  • "waiting for approval" stays until the tool call ends, approved or not: no event carries the person's answer. In auto or dontAsk mode, or for a background subagent, it can show briefly before an automatic deny; it clears when the call ends.
  • The engine's own forks (compaction, memory) are not shown, and events from loops no spawn or list named (a workflow's agents) are ignored.
  • /clear and /resume start the tree empty; a background agent that survives /clear comes back once the list names it (while something runs with the pane open). A reload of the plugin starts it empty too; run /flow to draw the pane again, and running agents come back once the list names them.
  • Based on the idea of claude-agent-flow by Charlie0113-T (Apache-2.0); no code is copied.

Use

# mods need Claude Code 2.1.287 or later
claude --plugin-dir /path/to/claude-mods-diegorv/plugins

That folder loads activity-log too, which writes everything the session sees to disk; to leave it out, point --plugin-dir at single plugins instead (--plugin-dir plugins/time).

Under --plugin-dir, edits to a plugin's files reload it without restarting the session.

Or install them from the marketplace on GitHub:

/plugin marketplace add diegorv/claude-mods-diegorv
/plugin install activity-log@claude-mods-diegorv
/plugin install agent-flow@claude-mods-diegorv
/plugin install gh-ci-status@claude-mods-diegorv
/plugin install time@claude-mods-diegorv

Or from a local clone, by the path of its root (the folder with .claude-plugin/marketplace.json, not plugins/):

/plugin marketplace add /path/to/claude-mods-diegorv
/plugin install activity-log@claude-mods-diegorv
/plugin install agent-flow@claude-mods-diegorv
/plugin install gh-ci-status@claude-mods-diegorv
/plugin install time@claude-mods-diegorv

A marketplace added from a local path loads its plugins in place, so edits need no version bump or reinstall; they take effect at the next session start or /reload-plugins, not on save as under --plugin-dir. Both marketplaces are named claude-mods-diegorv, and only one marketplace per name can be registered, so remove one before adding the other.

The marketplace was renamed, and the install ids with it. If you installed under the old name, run /plugin marketplace remove claude-function-hooks, which also uninstalls its plugins, then one of the two marketplace blocks above.

Tested with Claude Code 2.1.289; the API may still change between releases. /plugin names the mods the session loaded, in a line such as 1 mod active · time. The old CLAUDE_CODE_ENABLE_FUNCTION_HOOKS flag is ignored; remove it.

To turn one off, disable its plugin in the Installed tab of /plugin. To turn off every installed mod, start the session with --safe-mode, which also disables your other customizations, or, for every session, set "disableAllHooks": true in ~/.claude/settings.json, which also stops your settings hooks and custom status line.

Develop

npm test           # node --test, no dependencies
npm run typecheck  # tsc
npm run format     # prettier
npm run validate   # claude plugin validate

claude plugin test runs every *.test.ts in a plugin with the claude-code/testing kit; these use node:test, so it is not used here.

The API's type declarations are not in git. Claude Code writes them to plugins/<name>/.claude-plugin/types/ each time it loads the plugins with --plugin-dir (a one-prompt headless run, claude -p --plugin-dir plugins "ok", will do). Before npm run typecheck, load the plugins once, and again after updating Claude Code.

Plugin layout

plugins/gh-ci-status/
  .claude-plugin/plugin.json   manifest
  hooks/hooks.json             points at the entry module
  src/core/                    the run model and the text derived from it, no I/O
  src/utils/                   generic helpers (text)
  src/app/                     use cases (the poller, the start-up), dependencies injected
  src/infra/                   external clients (GitHub through gh)
  src/components/              the band's view model and its JSX
  src/hooks/                   the wiring to the engine
  *.test.ts                    next to the file it tests, run with node:test
Source 5 files
src/hooks/register.tsx 165 lines
1// Wires the model to the engine: its hooks, no logic of its own.
2//
3//   session.start               registers /flow, in an interactive session only
4//   session.end                 /clear and /resume forget the tree
5//   agent.spawn                 a started agent joins the tree under its parent
6//   tool.call                   a call is in flight from its start until next settles
7//   classic.PermissionRequest   a prompt no settings hook decided marks the call waiting
8//   turn.start, turn.complete   the main loop's turns; a subagent's run ends at its turn.complete
9//   ui.render                   draws the pane from the tree
10//   ui.close                    notes that the pane is gone, so the next /flow opens it
11//   command.run                 /flow opens or closes the pane
12//
13// While isBusy and the pane is drawn, the tree is reconciled with $.agent.list()
14// every 2 s and the pane redrawn every 1 s, so the clocks move; both timers stop
15// once all is idle or the pane closes. Events update the tree either way.
16import type { EngineInterface, Register, Timer } from "claude-code";
17import {
18  initialState,
19  onPermission,
20  onSpawn,
21  onToolEnd,
22  onToolStart,
23  onTurnComplete,
24  onTurnStart,
25  reconcile,
26  type FlowState,
27} from "../core/flow.ts";
28import { isBusy } from "../core/rows.ts";
29import { Pane } from "../components/pane.tsx";
30import { paneModel } from "../components/pane-model.ts";
31
32const PANE_ID = "flow";
33const TICK_MS = 1000;
34const LIST_MS = 2000;
35
36const logFailure = ($: EngineInterface) => (error: unknown) =>
37  $.ui.log(error instanceof Error ? error.message : String(error), { to: "debug" });
38
39// What the module keeps between hooks; a reload starts it over.
40type Flow = {
41  isActive: boolean; // an interactive session: -p and the SDK draw nowhere, so nothing is tracked
42  state: FlowState;
43  timers: Timer[];
44  paneDrawn: boolean; // this module drew the pane since it last opened; cleared when it closes
45};
46
47// Starts or stops the timers by whether anything runs in a drawn pane.
48function syncTimers($: EngineInterface, flow: Flow) {
49  const busy = isBusy(flow.state) && flow.paneDrawn;
50  if (busy && flow.timers.length === 0) {
51    flow.timers = [
52      $.clock.every(TICK_MS, () => $.ui.invalidate("ui.render")),
53      $.clock.every(LIST_MS, () => void readList($, flow).catch(logFailure($))),
54    ];
55  } else if (!busy && flow.timers.length > 0) {
56    for (const timer of flow.timers) timer.cancel();
57    flow.timers = [];
58  }
59}
60
61// Changes the tree, redraws, and starts or stops the timers.
62async function apply($: EngineInterface, flow: Flow, change: (state: FlowState, now: number) => FlowState) {
63  flow.state = change(flow.state, await $.clock.now());
64  $.ui.invalidate("ui.render");
65  syncTimers($, flow);
66}
67
68async function readList($: EngineInterface, flow: Flow) {
69  const listed = await $.agent.list();
70  await apply($, flow, (state, now) => reconcile(state, listed, now));
71}
72
73// Opened by the person, so it is placed at any width; it does not take the keyboard, being a view to glance at.
74async function togglePane($: EngineInterface, flow: Flow): Promise<string | undefined> {
75  const listed = (await $.ui.panes()).find((pane) => pane.id === PANE_ID);
76  if (listed?.isShown && flow.paneDrawn) {
77    await $.ui.close({ id: PANE_ID });
78    return "Closed the agent flow pane.";
79  }
80  // Listed but not drawn here is what a reload leaves, and opening that again does not draw it: close it first.
81  if (listed && !flow.paneDrawn) await $.ui.close({ id: PANE_ID });
82  const opened = await $.ui.open({ id: PANE_ID, title: "Agent flow" });
83  return opened.isPlaced ? undefined : `The agent flow pane did not open: ${opened.reason}`;
84}
85
86export const register: Register = (on) => {
87  const flow: Flow = { isActive: false, state: initialState(0), timers: [], paneDrawn: false };
88
89  on("session.start", ($, event, next) => {
90    flow.isActive = event.isInteractive;
91    if (flow.isActive) {
92      void $.command
93        .register({ name: "flow", description: "Show or hide the tree of this session's subagents and teammates" })
94        .catch(logFailure($));
95    }
96    return next(event);
97  });
98
99  on("session.end", async ($, event, next) => {
100    if (flow.isActive && (event.reason === "clear" || event.reason === "resume")) {
101      await apply($, flow, (_, now) => initialState(now));
102    }
103    return next(event);
104  });
105
106  on("agent.spawn", async ($, event, next) => {
107    const result = await next(event);
108    if (flow.isActive) await apply($, flow, (current, now) => onSpawn(current, event, result, now));
109    return result;
110  });
111
112  on("tool.call", async ($, event, next) => {
113    if (!flow.isActive) return next(event);
114    await apply($, flow, (current, now) => onToolStart(current, event, now));
115    try {
116      return await next(event);
117    } finally {
118      await apply($, flow, (current, now) => onToolEnd(current, event, now));
119    }
120  });
121
122  // The chain beneath is the settings hooks: a decision of theirs means no prompt is shown.
123  on("classic.PermissionRequest", async ($, event, next) => {
124    const result = await next(event);
125    if (flow.isActive && !result.decision && !result.block) {
126      await apply($, flow, (current, now) =>
127        onPermission(current, { agentId: event.agent_id, tool: event.tool_name }, now),
128      );
129    }
130    return result;
131  });
132
133  on("turn.start", async ($, event, next) => {
134    if (flow.isActive) await apply($, flow, (current, now) => onTurnStart(current, now));
135    return next(event);
136  });
137
138  on("turn.complete", async ($, event, next) => {
139    if (flow.isActive) await apply($, flow, (current, now) => onTurnComplete(current, event, now));
140    return next(event);
141  });
142
143  // Registered only in an interactive session (session.start), where the pane can draw.
144  on("command.run", { command: "flow" }, async ($) => {
145    const text = await togglePane($, flow);
146    return text ? { text } : {};
147  });
148
149  on("ui.render", { component: "Pane", surface: "terminal" }, async ($, event, next) => {
150    if (event.requestId !== PANE_ID) return next(event);
151    if (!flow.paneDrawn) {
152      flow.paneDrawn = true;
153      syncTimers($, flow);
154    }
155    return Pane($.ui.resolve(event), paneModel(flow.state, await $.clock.now()));
156  });
157
158  // The person's close or ours ends the drawing; the close goes on.
159  on("ui.close", { id: PANE_ID }, ($, event, next) => {
160    flow.paneDrawn = false;
161    syncTimers($, flow);
162    return next(event);
163  });
164};
165
src/core/flow.ts 190 lines
1// The tree's state and how each engine event changes it: the main loop at the
2// root, every subagent and teammate under the loop that spawned it. Plain data
3// (no Map, no class), every reducer returning a new state and taking `now`.
4import type { AgentInfo, AgentSpawnInput, AgentSpawnResult, AgentStatus, TurnCompleteInput } from "claude-code";
5
6// `gone`: a loop that left `$.agent.list()` while it still looked alive.
7export type Status = AgentStatus | "gone";
8
9// A tool call in flight; `isWaiting` while its permission prompt is open.
10export type Call = { id: string; tool: string; since: number; isWaiting: boolean };
11
12export type FlowNode = {
13  id: string;
14  parentId: string | null; // null: spawned by the main loop, or a parent the tree does not know
15  type: string;
16  description: string;
17  name: string | null;
18  isTeammate: boolean;
19  status: Status;
20  startedAt: number;
21  endedAt: number | null;
22  lastEventAt: number;
23  calls: Call[]; // oldest first
24  toolCalls: number; // ended calls
25  tokens: number; // input + output, summed over the loop's turns
26  isListed: boolean; // some list named it: only such a node can be judged gone
27};
28
29export type FlowState = { root: FlowNode; agents: Record<string, FlowNode> };
30
31export type SpawnFacts = Pick<
32  AgentSpawnInput,
33  "subagentType" | "description" | "name" | "parentAgentId" | "isTeammate"
34>;
35export type ToolStart = { agentId?: string; tool: string; tool_use_id: string };
36export type ToolEnd = { agentId?: string; tool_use_id: string };
37// From `classic.PermissionRequest`: `agent_id` and `tool_name` under these names.
38export type PermissionAsk = { agentId?: string; tool: string };
39export type TurnEnd = Pick<TurnCompleteInput, "agentId" | "reason" | "usage">;
40export type Listed = Pick<AgentInfo, "id" | "type" | "description" | "status" | "parentId" | "name" | "teammateId">;
41
42const ENDED: readonly Status[] = ["completed", "failed", "killed", "gone"];
43
44export const isEnded = (status: Status): boolean => ENDED.includes(status);
45
46function node(id: string, now: number, facts: Partial<FlowNode> = {}): FlowNode {
47  return {
48    id,
49    parentId: null,
50    type: "",
51    description: "",
52    name: null,
53    isTeammate: false,
54    status: "running",
55    startedAt: now,
56    endedAt: null,
57    lastEventAt: now,
58    calls: [],
59    toolCalls: 0,
60    tokens: 0,
61    isListed: false,
62    ...facts,
63  };
64}
65
66// Also what `/clear` and `/resume` leave: the main loop, idle, and no agent.
67export function initialState(now: number): FlowState {
68  return { root: node("main", now, { type: "main", status: "idle", endedAt: now }), agents: {} };
69}
70
71// Applies `change` to the loop an event names (absent: the main loop); an id
72// the tree does not know (a workflow's agent, a compaction fork) is ignored.
73function updateLoop(state: FlowState, agentId: string | undefined, change: (node: FlowNode) => FlowNode): FlowState {
74  if (agentId === undefined) return { ...state, root: change(state.root) };
75  const current = state.agents[agentId];
76  if (!current) return state;
77  return { ...state, agents: { ...state.agents, [agentId]: change(current) } };
78}
79
80// A started spawn adds a running node under its parent; one the list added
81// first keeps its status and takes the spawn's facts. A refused one adds none.
82export function onSpawn(state: FlowState, spawn: SpawnFacts, result: AgentSpawnResult, now: number): FlowState {
83  if (result.agentId === undefined) return state;
84  const existing = state.agents[result.agentId];
85  const facts = {
86    type: spawn.subagentType,
87    description: spawn.description,
88    // A teammate's listed name carries the suffix added when the requested one was taken.
89    name: (spawn.isTeammate === true ? existing?.name : null) ?? spawn.name ?? null,
90    isTeammate: spawn.isTeammate === true,
91    parentId: spawn.parentAgentId ?? existing?.parentId ?? null,
92    lastEventAt: now,
93  };
94  const next = existing ? { ...existing, ...facts } : node(result.agentId, now, facts);
95  return { ...state, agents: { ...state.agents, [next.id]: next } };
96}
97
98// A call starts: an agent runs (a message may wake an ended agent or an idle
99// teammate). The main loop's status is its turn's: a call outside one (a
100// plugin's `$.tool.call`) leaves it idle.
101export function onToolStart(state: FlowState, call: ToolStart, now: number): FlowState {
102  const isAgent = call.agentId !== undefined;
103  return updateLoop(state, call.agentId, (loop) => ({
104    ...loop,
105    status: isAgent ? "running" : loop.status,
106    endedAt: isAgent ? null : loop.endedAt,
107    lastEventAt: now,
108    calls: [...loop.calls, { id: call.tool_use_id, tool: call.tool, since: now, isWaiting: false }],
109  }));
110}
111
112// A call ends, answered, denied or failed: it is counted, even one whose start
113// came before its loop's spawn resolved.
114export function onToolEnd(state: FlowState, call: ToolEnd, now: number): FlowState {
115  return updateLoop(state, call.agentId, (loop) => ({
116    ...loop,
117    lastEventAt: now,
118    calls: loop.calls.filter((inFlight) => inFlight.id !== call.tool_use_id),
119    toolCalls: loop.toolCalls + 1,
120  }));
121}
122
123// A permission prompt opens: the request names no call, so it marks the
124// loop's oldest call of that tool not already waiting. The call's end clears it.
125export function onPermission(state: FlowState, ask: PermissionAsk, now: number): FlowState {
126  return updateLoop(state, ask.agentId, (loop) => {
127    const index = loop.calls.findIndex((call) => call.tool === ask.tool && !call.isWaiting);
128    if (index === -1) return loop;
129    const calls = loop.calls.map((call, at) => (at === index ? { ...call, isWaiting: true } : call));
130    return { ...loop, calls, lastEventAt: now };
131  });
132}
133
134// The main loop starts a turn; a subagent's run raises no `turn.start`.
135export function onTurnStart(state: FlowState, now: number): FlowState {
136  return { ...state, root: { ...state.root, status: "running", startedAt: now, endedAt: null, lastEventAt: now } };
137}
138
139const STATUS_OF_REASON: Record<TurnEnd["reason"], Status> = {
140  answer: "completed",
141  error: "failed",
142  refusal: "failed",
143  aborted: "killed",
144};
145
146// A loop's turn ends: the main loop and a teammate go idle, a subagent ends by
147// the reason. Calls still in flight ended with the turn; its tokens are added.
148export function onTurnComplete(state: FlowState, turn: TurnEnd, now: number): FlowState {
149  return updateLoop(state, turn.agentId, (loop) => ({
150    ...loop,
151    status: turn.agentId === undefined || loop.isTeammate ? "idle" : STATUS_OF_REASON[turn.reason],
152    endedAt: now,
153    lastEventAt: now,
154    calls: [],
155    tokens: loop.tokens + (turn.usage ? turn.usage.input_tokens + turn.usage.output_tokens : 0),
156  }));
157}
158
159// What `$.agent.list()` answers has the last word on status and fills a
160// parent the events never gave. Its name is taken for a teammate only (for
161// another agent it is the engine's handle, not a label). A node alive by the events
162// that a list named and the next one leaves out is gone; one no list ever named
163// (a loop the list may not cover, or a spawn the list was read before) is left to its events.
164export function reconcile(state: FlowState, listed: readonly Listed[], now: number): FlowState {
165  const agents: Record<string, FlowNode> = {};
166  const seen = new Set(listed.map((info) => info.id));
167  for (const loop of Object.values(state.agents)) {
168    if (seen.has(loop.id) || isEnded(loop.status) || !loop.isListed) agents[loop.id] = loop;
169    else agents[loop.id] = { ...loop, status: "gone", endedAt: now, lastEventAt: now, calls: [] };
170  }
171  for (const info of listed) {
172    const existing = agents[info.id];
173    const base =
174      existing ??
175      node(info.id, now, { type: info.type, description: info.description, isTeammate: info.teammateId !== undefined });
176    const isChanged = existing !== undefined && existing.status !== info.status;
177    agents[info.id] = {
178      ...base,
179      status: info.status,
180      endedAt: isEnded(info.status) ? (base.endedAt ?? now) : null,
181      lastEventAt: isChanged ? now : base.lastEventAt,
182      calls: isEnded(info.status) ? [] : base.calls,
183      parentId: base.parentId ?? info.parentId ?? null,
184      name: info.teammateId !== undefined ? (info.name ?? base.name) : base.name,
185      isListed: true,
186    };
187  }
188  return { ...state, agents };
189}
190
src/core/rows.ts 109 lines
1// The tree as the pane draws it: one row per loop in depth-first order, the
2// header's counts, and whether anything runs (the timers' condition).
3import { isEnded, type Call, type FlowNode, type FlowState, type Status } from "./flow.ts";
4
5const SLOW_TOOL_MS = 30_000;
6const STALE_MS = 120_000;
7
8// What makes a row stand out, strongest first.
9export type Signal = "approval" | "slow-tool" | "stale" | null;
10
11export type Row = {
12  id: string;
13  depth: number; // 0 for the main loop
14  status: Status;
15  type: string;
16  label: string; // name, or else description
17  elapsed: string;
18  activity: string | null; // the oldest call, or the prompt waiting
19  toolCalls: number;
20  tokens: string | null; // once ended
21  signal: Signal;
22};
23
24export type Counts = { running: number; done: number; waiting: number };
25
26// `12s`, `3m05s`, `1h02m`.
27export function duration(ms: number): string {
28  const seconds = Math.max(0, Math.floor(ms / 1000));
29  const twoDigits = (value: number) => String(value).padStart(2, "0");
30  if (seconds < 60) return `${seconds}s`;
31  if (seconds < 3600) return `${Math.floor(seconds / 60)}m${twoDigits(seconds % 60)}s`;
32  return `${Math.floor(seconds / 3600)}h${twoDigits(Math.floor(seconds / 60) % 60)}m`;
33}
34
35// `820 tok`, `4.1k tok`, `1.2M tok`.
36export function tokens(count: number): string {
37  if (count < 1000) return `${count} tok`;
38  if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k tok`;
39  return `${(count / 1_000_000).toFixed(1)}M tok`;
40}
41
42const waitingCall = (loop: FlowNode): Call | undefined => loop.calls.find((call) => call.isWaiting);
43
44// An Agent call runs as long as the foreground child it waits for, whose own row shows how that goes.
45function signalOf(loop: FlowNode, isRoot: boolean, now: number): Signal {
46  if (waitingCall(loop)) return "approval";
47  const oldest = loop.calls.find((call) => call.tool !== "Agent");
48  if (oldest && now - oldest.since > SLOW_TOOL_MS) return "slow-tool";
49  if (!isRoot && loop.status === "running" && now - loop.lastEventAt > STALE_MS) return "stale";
50  return null;
51}
52
53function activityOf(loop: FlowNode, now: number): string | null {
54  const waiting = waitingCall(loop);
55  if (waiting) return `waiting for approval: ${waiting.tool}`;
56  const oldest = loop.calls[0];
57  return oldest ? `${oldest.tool} ${duration(now - oldest.since)}` : null;
58}
59
60function rowOf(loop: FlowNode, depth: number, now: number): Row {
61  return {
62    id: loop.id,
63    depth,
64    status: loop.status,
65    type: loop.type,
66    label: loop.name ?? loop.description,
67    elapsed: duration((loop.endedAt ?? now) - loop.startedAt),
68    activity: activityOf(loop, now),
69    toolCalls: loop.toolCalls,
70    tokens: isEnded(loop.status) && loop.tokens > 0 ? tokens(loop.tokens) : null,
71    signal: signalOf(loop, depth === 0, now),
72  };
73}
74
75// The main loop, then each agent under its parent, siblings in spawn order; an
76// agent whose parent the tree does not know sits under the main loop.
77export function rowsOf(state: FlowState, now: number): Row[] {
78  const agents = Object.values(state.agents).sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id));
79  const parentOf = (loop: FlowNode) => (loop.parentId !== null && state.agents[loop.parentId] ? loop.parentId : null);
80  const rows: Row[] = [rowOf(state.root, 0, now)];
81  const walk = (parentId: string | null, depth: number) => {
82    for (const loop of agents) {
83      if (parentOf(loop) !== parentId) continue;
84      rows.push(rowOf(loop, depth, now));
85      walk(loop.id, depth + 1);
86    }
87  };
88  walk(null, 1);
89  return rows;
90}
91
92// The header: agents alive and not idle (pending and held ones too), agents
93// ended, and the loops (the main one too) waiting for approval.
94export function countsOf(state: FlowState): Counts {
95  const agents = Object.values(state.agents);
96  return {
97    running: agents.filter((loop) => !isEnded(loop.status) && loop.status !== "idle").length,
98    done: agents.filter((loop) => isEnded(loop.status)).length,
99    waiting: [state.root, ...agents].filter((loop) => waitingCall(loop)).length,
100  };
101}
102
103// Whether the clock and the list are worth reading: a turn or a call of the
104// main loop, or an agent neither ended nor idle.
105export function isBusy(state: FlowState): boolean {
106  if (state.root.status === "running" || state.root.calls.length > 0) return true;
107  return Object.values(state.agents).some((loop) => !isEnded(loop.status) && loop.status !== "idle");
108}
109
src/components/pane.tsx 34 lines
1// Maps the pane's view model to elements. No decisions here: see pane-model.ts.
2//
3// A .map() array goes inside a Fragment, and a bare Fragment lays out as a row
4// Box, so it lives inside a column Box.
5import type { Elements as EngineElements } from "claude-code";
6import type { PaneModel } from "./pane-model.ts";
7
8type Elements = Pick<EngineElements["terminal"], "Box" | "Text">;
9
10export function Pane({ Box, Text }: Elements, model: PaneModel) {
11  return (
12    <Box flexDirection="column">
13      <Text dimColor wrap="truncate-end">
14        {model.header}
15      </Text>
16      <Box flexDirection="column">
17        <>
18          {model.lines.map((line) => (
19            <Text
20              {...(line.color ? { color: line.color } : {})}
21              dimColor={line.isDim}
22              bold={line.isBold}
23              wrap="truncate-end"
24            >
25              {line.text}
26            </Text>
27          ))}
28        </>
29      </Box>
30      {model.note ? <Text dimColor>{model.note}</Text> : null}
31    </Box>
32  );
33}
34
src/components/pane-model.ts 66 lines
1// The pane's view model: the header's counts and one line per row of the tree,
2// with how each line is styled. pane.tsx maps it to elements and decides nothing.
3import { isEnded, type FlowState, type Status } from "../core/flow.ts";
4import { countsOf, rowsOf, type Row } from "../core/rows.ts";
5
6export type PaneLine = { text: string; color: string | null; isDim: boolean; isBold: boolean };
7
8export type PaneModel = {
9  header: string;
10  lines: PaneLine[];
11  note: string | null; // under the main loop while no agent has been seen
12};
13
14const GLYPHS: Record<Status, string> = {
15  pending: "○",
16  running: "●",
17  waiting: "◐",
18  idle: "○",
19  completed: "✓",
20  failed: "✗",
21  killed: "⊘",
22  gone: "?",
23};
24
25// Theme keys, so they follow the person's theme, except failures: raw red, as gh-ci-status does.
26// A running row is uncolored: the colors are kept for what is done or needs attention.
27const STATUS_COLORS: Partial<Record<Status, string>> = {
28  completed: "success",
29  failed: "red",
30};
31const SIGNAL_COLORS = { approval: "permission", "slow-tool": "warning", stale: "warning" } as const;
32
33// `● Explore: find the bug · running 12s · Bash 3s · 4 calls`; the main loop
34// reads `● main · running 12s · Bash 3s`, or `○ main · idle`.
35function textOf(row: Row): string {
36  const isRoot = row.depth === 0;
37  const name = isRoot ? "main" : row.label ? `${row.type}: ${row.label}` : row.type;
38  const clock = isRoot && row.status === "idle" ? "" : ` ${row.elapsed}`;
39  const parts = [
40    `${"  ".repeat(row.depth)}${GLYPHS[row.status]} ${name}`,
41    `${row.status}${clock}`,
42    row.activity,
43    isRoot || row.toolCalls === 0 ? null : `${row.toolCalls} ${row.toolCalls === 1 ? "call" : "calls"}`,
44    row.tokens,
45  ];
46  return parts.filter((part) => part !== null).join(" · ");
47}
48
49// A row that needs attention takes its signal's color and bold; an ended one is dimmed.
50const lineOf = (row: Row): PaneLine => ({
51  text: textOf(row),
52  color: row.signal ? SIGNAL_COLORS[row.signal] : (STATUS_COLORS[row.status] ?? null),
53  isDim: row.signal === null && isEnded(row.status),
54  isBold: row.signal !== null,
55});
56
57export function paneModel(state: FlowState, now: number): PaneModel {
58  const counts = countsOf(state);
59  const rows = rowsOf(state, now);
60  return {
61    header: `${counts.running} running · ${counts.done} done · ${counts.waiting} waiting`,
62    lines: rows.map(lineOf),
63    note: rows.length === 1 ? "No subagent or teammate yet." : null,
64  };
65}
66