Live graph of what Claude Code is doing and what comes next

<h1 align="center"> <img alt="FlowCTL: a live graph of what Claude Code is doing" src="docs/media/logo-light.png" width="560"> </h1>
<b>English</b> · <a href="README.pt-BR.md">Português</a>
What Claude Code is doing, what comes next and when it needs you,<br> in a small floating window on your Mac or Windows PC.
<a href="https://flowctl.vercel.app"><b>Website</b></a> · <a href="#install"><b>Install</b></a> · <a href="#features"><b>Features</b></a> · <a href="LICENSE"><b>MIT license</b></a>
<img alt="A Claude Code request becomes a graph: the plan arrives a step at a time, steps turn blue as they run and green when they are done, and two subagents branch out" src="docs/media/graph.gif" width="800">
You ask Claude Code for something, then go do something else while it works. You think it is working. But it may have stopped a while ago to ask you something, and you only find out when you come back. And when it is working, a long session is a wall of text: to know what it is doing, what is left and whether it needs you, you have to scroll back and read. With a few sessions open in different terminals, you end up checking each one in turn.
FlowCTL draws the session instead. The agent's plan becomes a graph: one node per step, arrows for what depends on what, colors for where each step is. It floats above your other windows, so a glance tells you how the work is going and whether a session needs you, without leaving what you are doing.
| Color | Meaning |
|---|---|
| white | pending |
| blue | in progress |
| yellow | needs you: a permission prompt or a question |
| red | failing: a tool error flashes it, repeated ones keep it red |
| green | done |
| faded, dashed | skipped: the turn ended without starting it (left out of the progress count) |
The clips come from the launch video, which adds captions and a few highlights of its own (the rings around the window and the graph, the prompt labels); everything else is the app as it runs.
Steps the agent declares (Claude Code's task tools, or FlowCTL's own FlowPlan tool) with their dependencies. Steps that run in parallel sit side by side. Subagents hang from the step that started them, or line up as their own timeline when the agent uses them without a plan. The camera follows the work while a session runs and shows the whole graph when it rests; drag to pan, pinch or scroll to zoom.
A tool error flashes its step red; repeated ones keep it red, so you see the agent working around a problem, or stuck on one. Hover a node for what it is about, how long it took and what came of it; click it to bring its terminal tab forward.
<img alt="A subagent's test fails twice: its node flashes red, then goes on; hovering it shows its summary" src="docs/media/error.gif" width="800">
A permission prompt or a question turns its step yellow, the menu bar (or tray) icon blinks, and a short sound plays. The window stays on top of whatever you are doing, and one click takes you to the terminal that is waiting.
<img alt="While a video plays, a step turns yellow on an rm -rf permission prompt; a click on it brings the terminal forward to answer" src="docs/media/yellow.gif" width="800">
The last step turns green and the turn ends: a glance at the window tells you the work is done, with no scrolling back through the terminal.
<img alt="The pull request step turns green, the terminal says the PR is open, and the whole graph is green" src="docs/media/done.gif" width="800">
Each new request continues the same graph to the right, so a session reads as one story. And every open session gets a page: ‹ 1/3 › switches between them, and when the page on screen is resting, the window turns to the session that starts working or needs you.
<img alt="A second prompt is typed and sent; its steps grow onto the same graph, right after the first prompt's last step" src="docs/media/prompt.gif" width="800">
Everything is in the menu bar (or tray) icon: two looks (pixel art with 8-bit sounds, or smooth with clean tones), sound or silence, theme, size, opacity, and where the window sits: drag it anywhere.
<img alt="From the menu bar: Look set to Smooth, Sound set to Off, then the window dragged to the bottom corner" src="docs/media/settings.gif" width="800">
After five minutes with nothing running, the window fades out until a session works again (Hide When Idle). Before that, the idle animation kicks in: after a minute, the nodes leave the graph and form a shape that turns slowly. Both are on by default and can be turned off in the menu.
<img alt="The window comes back from the menu bar and its nodes form a slowly turning shape" src="docs/media/dream.gif" width="800">
Requirements: macOS 13 or later, or Windows 10 or 11 (64-bit), and Claude Code 2.1.295 or later. FlowCTL has two parts, both installed from a Claude Code session:
/plugin install flowctl --marketplace MarcoFilho1/FlowCTL
/flowctl init
y), then installs the plugin. Choose the user scope so it works in every project. The plugin watches the session and builds the graph./flowctl init downloads the app of the same version from this repository's GitHub Releases, checks its SHA-256 and signature, installs it (no admin password) and opens it: in ~/Applications on a Mac, in %LOCALAPPDATA%\Programs\FlowCTL with a Start menu shortcut on Windows. The app is the floating window and the menu bar (or tray) icon.You install once. You do not run either command again when you open a new session:
Sessions that were already open when you installed the plugin: run /reload-plugins in each to load it. The instruction that asks the agent to declare a plan reaches it from the next session, so restart those sessions with claude --continue to keep their conversation and get the plan too.
How does the graph grow? Every prompt adds to it. Work of three or more steps shows up as the plan the agent declares (FlowCTL asks it to; you can turn that request off in the plugin's options); a quick question or a one-command fix adds a single node, named after your prompt. The session reads left to right, one prompt after another.
Updating. When a new version is out, update the plugin from /plugin (or claude plugin update flowctl@flowctl) and run /reload-plugins: the next session start updates the app too, with the same download and checks as /flowctl init. You can turn that off in the plugin's options and run /flowctl init yourself.
| Command | What it does |
|---|---|
/flowctl init | Installs or updates the overlay app to the plugin's version; opens it if it is already there |
/flowctl init --force | Installs it again |
/flowctl init --from-source | Builds it from the tagged source instead of downloading (macOS only; needs pnpm, Node 22 and Swift) |
/flowctl uninstall | Quits and removes the app (the plugin is removed from /plugin) |
The menu bar icon (on Windows, the tray icon: left click shows or hides the panel, right click opens the menu) holds the overlay's settings: show or hide the panel, opacity, size (small, medium, large), look (pixel or smooth), theme (auto, dark, light), sound, show subagents, idle animation, always on top, show automatically, hide when idle.
The plugin's options (/plugin, then flowctl):
| Option | Default | |
|---|---|---|
| Open the overlay | on | Open the app when a session starts, if it is installed |
| Update the overlay | on | Update the app at a session start when it is older than the plugin |
| Ask the agent to declare its plan | on | Without it, the graph is usually one node |
| Errors before red | 3 | Consecutive tool errors that turn the current step red |
| Record events | off | Keep the session's events in ~/.flowctl/recordings, for bug reports |
Everything stays on your computer. The plugin hands the app each graph over a local socket (~/.flowctl/overlay.sock; on Windows, 127.0.0.1 with a token from ~/.flowctl/overlay.json) and keeps the latest one per session in ~/.flowctl/sessions. A graph holds step titles, statuses and times, a short summary of what a step or subagent answered, and the first line of a tool error; never your prompts, file contents or full command output. Nothing is sent anywhere: the only network call is /flowctl init downloading the app from GitHub.
Claude Code session ──hooks──▶ plugin (mod/) ──snapshot──▶ overlay app (apps/overlay*)
tasks, subagents, builds the graph, a floating window drawing
permission prompts lays it out the graph (apps/viewer)
The plugin is a Claude Code mod: it observes the session's events (task tools, subagents, permission checks, tool errors) and never changes what the agent does, apart from the optional request to declare a plan. The app is a native panel (Swift on macOS, Tauri on Windows) hosting a small web view; the graph is drawn by the viewer, a React page bundled into the app.
mod/ the Claude Code plugin
hooks/ hooks module: register.ts
graph/ graph logic, pure TypeScript with no imports
live/ what the plugin shows and does: status line, rebuild, /flowctl
tests/ plugin tests, run by `claude plugin test`
types-vendor/ Claude Code's API types, versioned for CI
apps/viewer/ the graph, a React page (Vite, single file)
apps/overlay/ the macOS app (SwiftPM, no Xcode needed)
apps/overlay-tauri/ the Windows app (Tauri, Rust); see its README
tests/ unit tests for mod/, run by node --test
fixtures/ recorded sessions, replayed by the tests and the viewer
scripts/ install-overlay.sh builds and installs the app from source
docs/ roadmap, design notes and the README's media
Requires Node 22.6+, pnpm, Claude Code 2.1.295+ and, for the app, Swift (the Command Line Tools are enough).
pnpm install
pnpm typecheck # tsc over the plugin and the tests
pnpm test # unit tests
pnpm validate # claude plugin validate + claude plugin test
pnpm viewer:test # viewer tests
claude --plugin-dir ./mod # a session with the plugin from this folder, hot-reloaded
pnpm viewer:dev # the viewer in a browser; ?fixture=all replays the recordings
scripts/install-overlay.sh --open # build the app and install it
Don't run --plugin-dir alongside an installed copy of the plugin: the session would load it twice.
Releasing. Set the same version in mod/.claude-plugin/plugin.json, MOD_VERSION in mod/live/install.ts, apps/overlay/Resources/Info.plist, and apps/overlay-tauri/src-tauri/ (tauri.conf.json and Cargo.toml), then push a tag vX.Y.Z. The Release workflow builds the macOS app for Apple silicon and Intel and the Windows app for x64, and publishes FlowCTL-X.Y.Z-macos.zip and FlowCTL-X.Y.Z-windows-x64.zip with their SHA-256, which /flowctl init downloads. To try a build before tagging, serve a zip and its .sha256 under <base>/vX.Y.Z/ and start the session with FLOWCTL_RELEASE_BASE=<base>.
After a Claude Code upgrade, refresh mod/types-vendor/claude-code.d.ts from mod/.claude-plugin/types/claude-code/index.d.ts (written each time a session loads the mod) and bump CLAUDE_CODE_VERSION in CI.
hooks/register.ts 521 lines1import type { EngineInterface, PluginOptions, Register, ToolCallResult } from "claude-code";
2import { apply, FLOWPLAN_TOOL, initialState, type GraphState } from "../graph/reduce.ts";
3import { snapshot } from "../graph/snapshot.ts";
4import type { GraphEvent, Layout, Snapshot, TerminalInfo } from "../graph/types.ts";
5import { alerts, describeRequest, FLOWPLAN_SPEC, nudgeText, promptTitle, statusLine, summarize } from "../live/present.ts";
6import { appNotice, autoUpdate, HELP, init, newerVersion, openInBackground, parseArgs, uninstall, type InstallContext } from "../live/install.ts";
7import { homeOf, platformOf, type Platform } from "../live/platform.ts";
8import { rebuildEvents } from "../live/rebuild.ts";
9import { overlayClient, type OverlayClient, type OverlayIo } from "../live/transport.ts";
10import { parentPid } from "../live/windows.ts";
11import { omitKeys, Recording, trim } from "../recorder/recording.ts";
12
13// The live mod: every hook below observes one engine event, passes it on
14// untouched, and feeds it to the graph (mod/graph). Each change publishes a
15// snapshot to the overlay app (mod/live/transport.ts) and to
16// ~/.flowctl/sessions/<id>.json, and refreshes the status line. Nothing here
17// may fail or slow down a call: all I/O is fire-and-forget.
18//
19// The events reach the graph in the same shape the recorder stores them, so
20// the fixtures in fixtures/ replay exactly what a live session sees.
21
22// Tools whose input and result the graph reads. Every other tool call keeps
23// only its name, agent and outcome, so file contents and commands stay out.
24const GRAPH_TOOLS = new Set([
25 "TaskCreate",
26 "TaskUpdate",
27 "TaskGet",
28 "TaskList",
29 "TaskStop",
30 "TodoWrite",
31 "Agent",
32 "AskUserQuestion",
33 "SendMessage",
34 FLOWPLAN_TOOL
35]);
36
37// Fields of classic events, at any depth, that carry prompts, the model's
38// text, tool payloads or paths.
39const CLASSIC_DROPPED = new Set([
40 "prompt",
41 "delta",
42 "tool_input",
43 "tool_response",
44 "transcript_path",
45 "agent_transcript_path",
46 "scratchpad_dir",
47 "cwd",
48 "last_assistant_message"
49]);
50
51const PUBLISH_DELAY_MS = 100;
52/**
53 * How long an "ask" waits before it turns the step yellow: longer than the
54 * auto-mode classifier takes to decide, short next to a person deciding.
55 */
56const ASK_WAIT_MS = 5000;
57const RECORD_FLUSH_DELAY_MS = 500;
58
59type Entry = Omit<GraphEvent, "seq">;
60
61interface Settings {
62 errorThreshold: number;
63 nudge: boolean;
64 autoOpenOverlay: boolean;
65 autoUpdateApp: boolean;
66 recordEvents: boolean;
67}
68
69let settings: Settings = { errorThreshold: 3, nudge: true, autoOpenOverlay: true, autoUpdateApp: true, recordEvents: false };
70let graph: GraphState = initialState();
71let flowctlDir: string | undefined;
72let overlay: OverlayClient | undefined;
73// Readers drop a snapshot whose seq is not above the last one, and a reload
74// of this module starts its variables over: counting from the clock keeps
75// seq growing across reloads.
76let seq = Date.now();
77let lastSnapshot: Snapshot | undefined;
78let lastLayout: Layout | undefined;
79let publishScheduled = false;
80let planTool = "TaskCreate";
81let terminal: TerminalInfo | undefined;
82
83// Recorder (opt-in): the same events, kept as fixtures-to-be.
84let recording: Recording | undefined;
85let recordingSession: string | undefined;
86let recordScheduled = false;
87let recordFlushing: Promise<void> = Promise.resolve();
88
89function readSettings(options: PluginOptions): Settings {
90 const threshold = Number(options["errorThreshold"]);
91 return {
92 errorThreshold: Number.isFinite(threshold) && threshold >= 1 ? threshold : 3,
93 // On unless turned off: without it the agent rarely declares a plan.
94 nudge: options["nudge"] !== false,
95 autoOpenOverlay: options["autoOpenOverlay"] !== false,
96 autoUpdateApp: options["autoUpdateApp"] !== false,
97 recordEvents: options["recordEvents"] === true
98 };
99}
100
101/** One engine event: into the graph, into the recording, then out to the surfaces. */
102function observe($: EngineInterface, event: string, fields: Omit<Entry, "t" | "event">): void {
103 try {
104 const entry: Entry = { t: Date.now(), event, ...fields };
105 apply(graph, entry);
106 if (settings.recordEvents) {
107 record($, entry);
108 }
109 schedulePublish($);
110 } catch {
111 // A lost event is better than a failed hook.
112 }
113}
114
115function schedulePublish($: EngineInterface): void {
116 if (publishScheduled) {
117 return;
118 }
119 publishScheduled = true;
120 $.clock.after(PUBLISH_DELAY_MS, () => {
121 publishScheduled = false;
122 publish($);
123 });
124}
125
126function publish($: EngineInterface): void {
127 try {
128 seq += 1;
129 const next = snapshot(graph, {
130 seq,
131 now: Date.now(),
132 ...(lastLayout ? { previous: lastLayout } : {}),
133 ...(terminal ? { terminal } : {})
134 });
135 lastLayout = next.layout;
136 for (const line of alerts(lastSnapshot, next)) {
137 $.ui.toast(line);
138 }
139 lastSnapshot = next;
140 $.ui.status(statusLine(next));
141 if (flowctlDir === undefined || next.sessionId === "") {
142 return;
143 }
144 const body = JSON.stringify(next);
145 void overlay?.request("POST", "/snapshot", body).catch(() => undefined);
146 void $.fs.write(`${flowctlDir}/sessions/${next.sessionId}.json`, body).catch(() => undefined);
147 } catch {
148 // The next event publishes again.
149 }
150}
151
152/** The host's platform: Windows by its OS variable, else what uname says. */
153async function hostPlatform($: EngineInterface): Promise<Platform> {
154 const osName = await $.env.get("OS");
155 const uname = osName === "Windows_NT" ? "" : await $.process.run(["uname", "-s"]).then((run) => run.stdout).catch(() => "");
156 return platformOf(osName, uname);
157}
158
159/** The host's file and HTTP calls, for the overlay client. */
160function overlayIo($: EngineInterface): OverlayIo {
161 return {
162 fetch: (url, init) => $.http.fetch(url, init),
163 read: (path) => $.fs.read(path),
164 mtime: async (path) => (await $.fs.stat(path)).mtimeMs
165 };
166}
167
168/**
169 * Where this session runs: the terminal app, its tty and its own ids, so the
170 * overlay can bring this terminal forward when a node is clicked.
171 */
172async function findTerminal($: EngineInterface, platform: Platform): Promise<TerminalInfo> {
173 // $.env.get takes literal names, so the variables the mod reads are listed.
174 const read = (value: string | undefined) => (value === undefined || value === "" ? undefined : value);
175 const ids: Record<string, string> = {};
176 const iterm = read(await $.env.get("ITERM_SESSION_ID").catch(() => undefined));
177 const apple = read(await $.env.get("TERM_SESSION_ID").catch(() => undefined));
178 const maestri = read(await $.env.get("MAESTRI_TERMINAL_ID").catch(() => undefined));
179 const windowsTerminal = read(await $.env.get("WT_SESSION").catch(() => undefined));
180 if (iterm !== undefined) ids["ITERM_SESSION_ID"] = iterm;
181 if (apple !== undefined) ids["TERM_SESSION_ID"] = apple;
182 if (maestri !== undefined) ids["MAESTRI_TERMINAL_ID"] = maestri;
183 if (windowsTerminal !== undefined) ids["WT_SESSION"] = windowsTerminal;
184 const app = read(await $.env.get("__CFBundleIdentifier").catch(() => undefined));
185 const program = read(await $.env.get("TERM_PROGRAM").catch(() => undefined));
186 // The shell's parent is this Claude Code process: its pid, and on Unix its
187 // tty, which is the session's (Windows has no tty).
188 const [pidText = "", tty = ""] = await $.process
189 .run(platform === "win32" ? parentPid() : ["sh", "-c", "echo $PPID; ps -o tty= -p $PPID"])
190 .then((run) => run.stdout.trim().split(/\s+/))
191 .catch(() => []);
192 const pid = Number(pidText);
193 return {
194 ...(app !== undefined ? { app } : {}),
195 ...(program !== undefined ? { program } : {}),
196 ...(tty !== "" && tty !== "??" ? { tty } : {}),
197 ...(Number.isInteger(pid) && pid > 1 ? { pid } : {}),
198 ...(Object.keys(ids).length > 0 ? { ids } : {})
199 };
200}
201
202/**
203 * Replays the plan the transcript already holds: after a reload of the mod
204 * (its memory starts over) or when a session is resumed, the graph would
205 * otherwise be empty until the agent touched its plan again.
206 */
207async function rebuildFromTranscript($: EngineInterface): Promise<void> {
208 const messages = await $.session.messages();
209 if (!Array.isArray(messages) || messages.length === 0) {
210 return;
211 }
212 const agents = await $.agent.list().catch(() => []);
213 const events = rebuildEvents(messages, { flowPlanTool: FLOWPLAN_TOOL, agents, at: Date.now() });
214 if (events.length === 0) {
215 return;
216 }
217 const { sessionId, cwd } = graph;
218 graph = initialState({ errorThreshold: settings.errorThreshold });
219 graph.sessionId = sessionId;
220 graph.cwd = cwd;
221 for (const event of events) {
222 apply(graph, event);
223 }
224 schedulePublish($);
225}
226
227/** The newer plugin version already announced, and when the folder was last looked at. */
228let announced: string | undefined;
229let lookedAt = 0;
230/** At a session start, then at most once a day while it stays open. */
231const LOOK_EVERY_MS = 24 * 60 * 60 * 1000;
232
233/**
234 * A session that stays open keeps the plugin it started with. When a newer
235 * version lands next to it (an update from /plugin, in this session or
236 * another), say once that it, and the overlay app with it, comes with the
237 * next session start or /reload-plugins. Only the local plugin folder is
238 * read; nothing goes out.
239 */
240async function lookForNewer($: EngineInterface): Promise<void> {
241 const now = Date.now();
242 if (now - lookedAt < LOOK_EVERY_MS) return;
243 lookedAt = now;
244 const root = $.plugin.root.replace(/[\\/]+$/, "");
245 const parent = root.slice(0, Math.max(root.lastIndexOf("/"), root.lastIndexOf("\\")));
246 if (parent === "") return;
247 const entries = await $.fs.list(parent);
248 const newer = newerVersion(entries.filter((entry) => entry.kind === "dir").map((entry) => entry.name));
249 if (newer === undefined || newer === announced) return;
250 announced = newer;
251 $.ui.toast(`FlowCTL ${newer} is ready: the plugin and the overlay app update with the next session start, or now with /reload-plugins.`);
252}
253
254/** Whether this load of the plugin has done its start (the session may have started before a reload). */
255let started = false;
256
257/**
258 * At a session start: an app older than this mod is updated (it is opened
259 * again after), whether or not it is running; else the app is opened when it
260 * is installed and not already listening.
261 */
262async function startOverlay($: EngineInterface, client: OverlayClient): Promise<void> {
263 if (settings.autoUpdateApp) {
264 const updated = await autoUpdate(await installContext($)).catch(() => undefined);
265 if (updated !== undefined) {
266 $.ui.toast(updated);
267 $.ui.status(undefined);
268 if (!updated.includes("could not be updated")) return;
269 }
270 }
271 if (settings.autoOpenOverlay) {
272 await openOverlay($, client);
273 }
274}
275
276/** Opens the overlay app when it is installed and not already listening. */
277async function openOverlay($: EngineInterface, client: OverlayClient): Promise<void> {
278 const alive = await client.request("GET", "/health");
279 if (alive) {
280 return;
281 }
282 // Not installed, or older than this mod: say how to fix it, once per session.
283 const ctx = await installContext($);
284 const notice = await appNotice(ctx).catch(() => undefined);
285 if (notice) {
286 $.ui.toast(notice);
287 if (notice.includes("not installed")) return;
288 }
289 await openInBackground(ctx).catch(() => undefined);
290}
291
292async function installContext($: EngineInterface): Promise<InstallContext> {
293 const releaseBase = await $.env.get("FLOWCTL_RELEASE_BASE");
294 const localAppData = await $.env.get("LOCALAPPDATA");
295 const appData = await $.env.get("APPDATA");
296 const temp = (await $.env.get("TEMP")) ?? (await $.env.get("TMP"));
297 return {
298 home: homeOf(await $.env.get("HOME"), await $.env.get("USERPROFILE")),
299 platform: await hostPlatform($),
300 ...(localAppData ? { localAppData } : {}),
301 ...(appData ? { appData } : {}),
302 ...(temp ? { temp } : {}),
303 ...(releaseBase ? { releaseBase } : {}),
304 run: (argv, timeoutMs) =>
305 $.process.run(argv, timeoutMs === undefined ? {} : { timeoutMs }).catch((error: unknown) => ({ exitCode: 127, stdout: "", stderr: String(error) })),
306 progress: (line) => $.ui.status(line)
307 };
308}
309
310/**
311 * The plan source (docs/roadmap-agent-graph.md, Phase 1 gate): the session's
312 * own task tools when it has them, else the FlowPlan tool this mod serves.
313 */
314async function choosePlanTool($: EngineInterface): Promise<void> {
315 const tools = await $.tool.list();
316 if (tools.some((tool) => tool.name === "TaskCreate")) {
317 planTool = "TaskCreate";
318 return;
319 }
320 const registered = await $.tool.register(FLOWPLAN_SPEC);
321 planTool = registered.tool;
322}
323
324function record($: EngineInterface, entry: Entry): void {
325 if (flowctlDir === undefined) {
326 return;
327 }
328 const sessionId = graph.sessionId;
329 if (sessionId !== undefined && sessionId !== recordingSession) {
330 flushRecording($);
331 recordingSession = sessionId;
332 recording = new Recording(`${flowctlDir}/recordings/${sessionId}`, String(Date.now()));
333 }
334 recording?.add(entry);
335 if (!recordScheduled) {
336 recordScheduled = true;
337 $.clock.after(RECORD_FLUSH_DELAY_MS, () => {
338 recordScheduled = false;
339 void flushRecording($);
340 });
341 }
342}
343
344function flushRecording($: EngineInterface): Promise<void> {
345 // Taken now, so a session switch right after cannot redirect these writes.
346 const writes = recording?.flush() ?? [];
347 recordFlushing = recordFlushing.then(async () => {
348 for (const write of writes) {
349 await $.fs.write(write.file, write.text).catch(() => undefined);
350 }
351 });
352 return recordFlushing;
353}
354
355/**
356 * Serves the FlowPlan tool: the plan itself reaches the graph as the call is
357 * observed. A plugin tool's result must be text or MCP content blocks; any
358 * other shape fails the call for the model, which then stops updating the plan.
359 */
360function serveFlowPlan(e: Record<string, unknown>): ToolCallResult {
361 const steps = Array.isArray(e["steps"]) ? e["steps"].length : 0;
362 const text = `Plan updated: ${steps} step(s). The person sees it as a live graph.`;
363 return { result: [{ type: "text", text }], text };
364}
365
366export const register: Register = (on, options) => {
367 settings = readSettings(options);
368 graph = initialState({ errorThreshold: settings.errorThreshold });
369
370 on("session.start", async ($, e, next) => {
371 const result = await next(e);
372 try {
373 const platform = await hostPlatform($);
374 const home = homeOf(await $.env.get("HOME"), await $.env.get("USERPROFILE"));
375 if (home !== "") {
376 flowctlDir = `${home}/.flowctl`;
377 overlay = overlayClient(overlayIo($), flowctlDir, platform);
378 }
379 graph.sessionId ??= await $.session.id();
380 terminal = await findTerminal($, platform).catch(() => undefined);
381 observe($, "session.start", { input: { cwd: e.cwd } });
382 await choosePlanTool($).catch(() => undefined);
383 await rebuildFromTranscript($).catch(() => undefined);
384 await $.command
385 .register({ name: "flowctl", description: "Install, update or remove the FlowCTL overlay app", argumentHint: "init | uninstall" })
386 .catch(() => undefined);
387 if (overlay !== undefined) {
388 started = true;
389 void startOverlay($, overlay).catch(() => undefined);
390 }
391 void lookForNewer($).catch(() => undefined);
392 } catch {
393 // Without its home folder the mod still draws the status line.
394 }
395 return result;
396 });
397
398 on("command.run", { command: "flowctl" }, async ($, e) => {
399 const { action, options } = parseArgs(e.args);
400 if (action === "help") return { text: HELP };
401 const ctx = await installContext($);
402 const text = action === "init" ? await init(ctx, options) : await uninstall(ctx);
403 if (lastSnapshot) $.ui.status(statusLine(lastSnapshot));
404 return { text };
405 });
406
407 on("session.end", async ($, e, next) => {
408 observe($, "session.end", { input: trim(e) });
409 publish($);
410 if (settings.recordEvents) {
411 await flushRecording($);
412 }
413 return next(e);
414 });
415
416 on("prompt.submit", async ($, e, next) => {
417 // Where it came from: the person (composer, bridge, sdk) or the engine
418 // (a background agent's task notification, an auto-continuation...).
419 const title = promptTitle(e.text);
420 observe($, "prompt.submit", {
421 input: { chars: e.text.length, ...(title !== undefined ? { title } : {}), ...(e.origin ? { origin: e.origin.kind } : {}) }
422 });
423 // Loaded again mid-session (/reload-plugins) without a session start: do the start's app check now.
424 if (!started && overlay !== undefined) {
425 started = true;
426 void startOverlay($, overlay).catch(() => undefined);
427 }
428 void lookForNewer($).catch(() => undefined);
429 return next(e);
430 });
431
432 on("prompt.compose", async ($, e, next) => {
433 const result = await next(e);
434 if (!settings.nudge) {
435 return result;
436 }
437 return { ...result, sections: [...result.sections, { id: "flowctl-nudge", text: nudgeText(planTool), scope: "session" as const }] };
438 });
439
440 on("turn.start", async ($, e, next) => {
441 const result = await next(e);
442 observe($, "turn.start", { input: { turnId: e.turnId } });
443 return result;
444 });
445
446 on("turn.complete", async ($, e, next) => {
447 const result = await next(e);
448 const { answer, ...fields } = e;
449 const agents = await $.agent.list().catch(() => []);
450 const summary = summarize(answer);
451 observe($, "turn.complete", {
452 ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
453 input: { ...(trim(fields) as object), ...(summary !== undefined ? { summary } : {}) },
454 result: { agents: agents.map(({ id, status, parentId, description, type }) => ({ id, status, parentId, description, type })) }
455 });
456 return result;
457 });
458
459 on("tool.call", async ($, e, next) => {
460 const result = e.tool === FLOWPLAN_TOOL ? serveFlowPlan(e as Record<string, unknown>) : await next(e);
461 const outcome = { isError: result.isError, deny: result.deny };
462 const agent = e.agentId !== undefined ? { agentId: e.agentId } : {};
463 if (GRAPH_TOOLS.has(e.tool)) {
464 // An Agent call's prompt is the subagent's brief: code and context the
465 // graph does not need. Its description names the node.
466 const { tool: _tool, agentId: _agentId, prompt: _prompt, ...input } = e as typeof e & { prompt?: unknown };
467 observe($, "tool.call", {
468 ...agent,
469 input: { tool: e.tool, ...(trim(input) as object) },
470 result: { ...outcome, result: trim(result.result) }
471 });
472 } else {
473 observe($, "tool.call", {
474 ...agent,
475 input: { tool: e.tool, tool_use_id: e.tool_use_id },
476 result: { ...outcome, text: result.isError ? trim(result.text?.slice(0, 300)) : undefined }
477 });
478 }
479 return result;
480 });
481
482 on("tool.check", async ($, e, next) => {
483 const result = await next(e);
484 // Why it asks, in words, only when it asks: the hover card shows it.
485 const reason = result.decision === "ask" ? describeRequest(e.tool, e.input) : undefined;
486 observe($, "tool.check", {
487 ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
488 input: { tool: e.tool, tool_use_id: e.tool_use_id, ceiling: e.ceiling, ...(reason !== undefined ? { reason } : {}) },
489 result: { decision: result.decision, reason: result.reason, rule: result.rule }
490 });
491 // Still not run in a few seconds: the person is deciding (see "ask.wait" in the graph).
492 if (result.decision === "ask" && e.tool !== "AskUserQuestion") {
493 const call = { ...(e.agentId !== undefined ? { agentId: e.agentId } : {}), input: { tool: e.tool, tool_use_id: e.tool_use_id } };
494 $.clock.after(ASK_WAIT_MS, () => observe($, "ask.wait", call));
495 }
496 return result;
497 });
498
499 on("agent.spawn", async ($, e, next) => {
500 const result = await next(e);
501 const { prompt: _prompt, ...input } = e as typeof e & { prompt?: unknown };
502 observe($, "agent.spawn", { input: trim(input), result: trim(result) });
503 return result;
504 });
505
506 on("classic.*", async ($, e, next) => {
507 const result = await next(e);
508 // classic.PreToolUse carries the bare tool call (no hook_event_name, every
509 // argument): tool.call already observes what the graph needs from it.
510 if (!("hook_event_name" in e)) {
511 return result;
512 }
513 const fields = omitKeys(e, CLASSIC_DROPPED) as Record<string, unknown>;
514 observe($, `classic.${String(fields["hook_event_name"])}`, { input: trim(fields) });
515 if (fields["hook_event_name"] === "SessionStart" && fields["source"] === "resume") {
516 await rebuildFromTranscript($).catch(() => undefined);
517 }
518 return result;
519 });
520};
521graph/reduce.ts 1025 lines1// The reducer: engine events in, graph state out. It never throws and never
2// refuses an event; what does not fit becomes a warning. Every rule here is
3// backed by a fixture (docs/events.md).
4//
5// `apply` mutates the state it is given (the caller owns it), so one event
6// costs work proportional to that event, not to the session. Replaying the
7// same events into a fresh state always yields the same state.
8
9import {
10 DEFAULT_ERROR_THRESHOLD,
11 displayStatus,
12 errorDetail,
13 isStoppedByUser,
14 type BaseStatus
15} from "./status.ts";
16import type { Attention, GraphEvent, GraphNode, SessionState } from "./types.ts";
17
18/**
19 * The mod's own plan tool, registered when the session has no task tools; the
20 * engine names a plugin's tool mcp__<plugin>__<name>.
21 */
22export const FLOWPLAN_TOOL = "mcp__flowctl__FlowPlan";
23
24const MAIN = "main";
25
26interface PlanEntry {
27 id: string;
28 kind: "task" | "plan";
29 title: string;
30 activeForm?: string;
31 description?: string;
32 startedT?: number;
33 finishedT?: number;
34 base: BaseStatus;
35 agentId?: string;
36 blockedBy: string[];
37 attention?: Attention;
38 paused: boolean;
39 /** Still pending when its loop's turn ended (see GraphNode.skipped). */
40 skipped?: boolean;
41 /** When it last became in_progress; picks the "current" task of a loop. */
42 startedAt?: number;
43 createdAt: number;
44 /** Which of the person's requests in this session it was planned in. */
45 request: number;
46 lastError?: { detail: string; at: number };
47}
48
49interface AgentEntry {
50 id: string;
51 agentId: string;
52 title: string;
53 summary?: string;
54 startedT: number;
55 finishedT?: number;
56 lastError?: { detail: string; at: number };
57 state: "running" | "done" | "failed";
58 parent?: string;
59 attention?: Attention;
60 createdAt: number;
61 /** Which of the person's requests spawned it. */
62 request: number;
63}
64
65/**
66 * One of the person's requests (a prompt). While no plan step or subagent
67 * stands for it, it is a node of its own, so every prompt leaves its mark on
68 * the session's timeline.
69 */
70interface PromptEntry {
71 id: string;
72 request: number;
73 /** The prompt's first line, cut short. */
74 title: string;
75 createdAt: number;
76 startedT?: number;
77 finishedT?: number;
78 summary?: string;
79}
80
81interface LoopState {
82 /** Consecutive tool batches that failed. */
83 errorStreak: number;
84 /** The current batch already counted a failure: parallel calls fail together. */
85 batchFailed: boolean;
86 /** A call of the current batch has returned: the loop's next check starts a new batch. */
87 sawResult?: boolean;
88}
89
90export interface GraphState {
91 sessionId?: string;
92 cwd?: string;
93 planSource: "tasks" | "flowplan" | "none";
94 entries: Map<string, PlanEntry>;
95 agents: Map<string, AgentEntry>;
96 loops: Map<string, LoopState>;
97 /** The loop (and why) of the last permission check that asked, per tool name. */
98 askedBy: Map<string, { loop: string; reason?: string }>;
99 /**
100 * Calls put to the mode's decider and not run yet, by call id: still there
101 * when the wait event comes, the person is the one deciding (see "ask.wait").
102 */
103 asks: Map<string, { loop: string; tool: string; reason?: string; since: number }>;
104 /**
105 * A classic hook event has reached the plugin: where they come (macOS), the
106 * PermissionRequest hook says exactly when the person is asked, and an ask
107 * alone never turns yellow. They don't come everywhere (Windows).
108 */
109 classicHooks: boolean;
110 turn: {
111 n: number;
112 running: boolean;
113 attention?: Attention;
114 startedT?: number;
115 finishedT?: number;
116 summary?: string;
117 lastError?: { detail: string; at: number };
118 };
119 /** The person's requests so far (prompt submissions); plan entries remember theirs. */
120 request: number;
121 /** Each request's prompt, by request number. */
122 prompts: Map<number, PromptEntry>;
123 /** FlowPlan step id → the node it names now (a step re-planned in a later request gets a new node). */
124 steps: Map<string, string>;
125 ended: boolean;
126 warnings: string[];
127 errorThreshold: number;
128 clock: number;
129}
130
131export function initialState(options: { errorThreshold?: number } = {}): GraphState {
132 return {
133 planSource: "none",
134 entries: new Map(),
135 agents: new Map(),
136 loops: new Map(),
137 askedBy: new Map(),
138 asks: new Map(),
139 classicHooks: false,
140 turn: { n: 0, running: false },
141 request: 0,
142 prompts: new Map(),
143 steps: new Map(),
144 ended: false,
145 warnings: [],
146 errorThreshold: options.errorThreshold ?? DEFAULT_ERROR_THRESHOLD,
147 clock: 0
148 };
149}
150
151/** Applies one event to `state` in place and returns it. */
152export function apply(state: GraphState, event: GraphEvent): GraphState {
153 state.clock += 1;
154 const input = record(event.input);
155 const result = record(event.result);
156
157 if (event.event.startsWith("classic.")) {
158 state.classicHooks = true;
159 }
160 switch (event.event) {
161 case "session.start":
162 state.cwd = text(input["cwd"]) ?? state.cwd;
163 break;
164 case "classic.SessionStart":
165 if (input["source"] === "clear") {
166 reset(state);
167 }
168 state.sessionId = text(input["session_id"]) ?? state.sessionId;
169 break;
170 case "session.end":
171 case "classic.SessionEnd":
172 // /clear ends this session too: its last snapshot must say so, or it
173 // stays frozen in whatever state it had. The new one starts at the
174 // SessionStart that follows (source "clear"), which resets the graph.
175 state.ended = true;
176 break;
177 case "prompt.submit":
178 // Only the person's prompts start a new request. A background agent's
179 // result arrives as a submission too (origin "task-notification"), and
180 // must not split the request it belongs to.
181 if (isPersonPrompt(input["origin"])) onPersonPrompt(state, event, input);
182 clearAttention(state, (attention) => attention.kind !== "error");
183 break;
184 case "turn.start":
185 if (event.agentId === undefined) {
186 state.turn = { n: state.turn.n + 1, running: true, startedT: event.t };
187 const prompt = state.prompts.get(state.request);
188 if (prompt !== undefined && prompt.startedT === undefined) prompt.startedT = event.t;
189 }
190 break;
191 case "turn.complete":
192 onTurnComplete(state, event, input, result);
193 break;
194 case "agent.spawn":
195 onAgentSpawn(state, event, input, result);
196 break;
197 case "tool.check": {
198 const loop = event.agentId ?? MAIN;
199 const tool = text(input["tool"]) ?? "";
200 const reason = text(input["reason"]);
201 // Calls the model makes together are all checked before any returns; a
202 // check after a result is the model's next batch (PostToolBatch, where it
203 // comes, says the same; it does not come everywhere, e.g. on Windows).
204 const loopState = loopOf(state, loop);
205 if (loopState.sawResult) {
206 loopState.batchFailed = false;
207 loopState.sawResult = false;
208 }
209 if (result["decision"] === "ask") {
210 state.askedBy.set(tool, { loop, ...(reason !== undefined ? { reason } : {}) });
211 if (tool === "AskUserQuestion") {
212 setAttention(state, loop, { kind: "question", detail: tool, ...(reason !== undefined ? { reason } : {}), since: event.t });
213 } else {
214 // "ask" puts the call to the mode's decider: the person, or in auto
215 // mode a classifier that decides in moments. Yellow waits for the
216 // plugin's "ask.wait", a few seconds on, if the call has not run by
217 // then; the PermissionRequest hook, where it comes, says at once
218 // that the person was asked.
219 state.asks.set(askId(input, loop), { loop, tool, ...(reason !== undefined ? { reason } : {}), since: event.t });
220 }
221 } else if (tool === "AskUserQuestion" && result["decision"] !== "deny") {
222 // A question waits on the person even when the permission mode lets it through unasked.
223 setAttention(state, loop, { kind: "question", detail: tool, since: event.t });
224 }
225 break;
226 }
227 case "ask.wait": {
228 // A call asked about a while ago has not run: the person is deciding.
229 const id = askId(input, event.agentId ?? MAIN);
230 const ask = state.asks.get(id);
231 if (ask !== undefined && !state.classicHooks) {
232 state.asks.delete(id);
233 setAttention(state, ask.loop, { kind: "permission", detail: ask.tool, ...(ask.reason !== undefined ? { reason: ask.reason } : {}), since: ask.since });
234 }
235 break;
236 }
237 case "classic.PermissionRequest":
238 onPermissionRequest(state, input, event.t);
239 break;
240 case "classic.PostToolBatch":
241 // A batch of tool calls ended; its loop's next failure starts a new attempt.
242 for (const loopState of state.loops.values()) {
243 loopState.batchFailed = false;
244 loopState.sawResult = false;
245 }
246 break;
247 case "tool.call":
248 onToolCall(state, event, input, result);
249 break;
250 default:
251 break;
252 }
253 return state;
254}
255
256/** Applies every event, in order, to a fresh state. */
257export function replay(events: readonly GraphEvent[], options?: { errorThreshold?: number }): GraphState {
258 const state = initialState(options);
259 for (const event of events) {
260 apply(state, event);
261 }
262 return state;
263}
264
265/**
266 * The nodes the state shows, in creation order: plan entries, agents, then
267 * the prompts no step stands for. With none, one node for the current turn,
268 * so the graph is never empty.
269 */
270export function nodes(state: GraphState): GraphNode[] {
271 const out: GraphNode[] = [];
272 const prompts = loosePrompts(state);
273 const known = new Set([
274 ...state.entries.keys(),
275 ...[...state.agents.values()].map((agent) => agent.id),
276 ...prompts.map((prompt) => prompt.id)
277 ]);
278
279 const after = sequenceLinks(state, known);
280 for (const entry of [...state.entries.values()].sort(byCreation)) {
281 const follows = after.get(entry.id);
282 out.push({
283 id: entry.id,
284 kind: "task",
285 title: entry.title,
286 ...(entry.activeForm !== undefined ? { activeForm: entry.activeForm } : {}),
287 ...(entry.description !== undefined ? { description: entry.description } : {}),
288 ...(entry.startedT !== undefined ? { startedAt: entry.startedT } : {}),
289 ...(entry.finishedT !== undefined ? { finishedAt: entry.finishedT } : {}),
290 status: displayStatus(entry.base, entry.attention, entry.paused),
291 ...(entry.attention !== undefined ? { attention: entry.attention } : {}),
292 ...(entry.agentId !== undefined ? { agentId: entry.agentId } : {}),
293 blockedBy: entry.blockedBy.filter((id) => known.has(id)),
294 ...(follows !== undefined ? { after: follows } : {}),
295 ...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
296 ...(entry.skipped === true && entry.base === "pending" ? { skipped: true } : {})
297 });
298 }
299 for (const agent of [...state.agents.values()].sort(byCreation)) {
300 const base: BaseStatus = agent.state === "running" ? "in_progress" : "completed";
301 const attention: Attention | undefined =
302 agent.state === "failed" ? { kind: "error", detail: "subagent failed", since: 0 } : agent.attention;
303 out.push({
304 id: agent.id,
305 kind: "agent",
306 title: agent.title,
307 ...(agent.summary !== undefined ? { summary: agent.summary } : {}),
308 startedAt: agent.startedT,
309 ...(agent.finishedT !== undefined ? { finishedAt: agent.finishedT } : {}),
310 status: agent.state === "failed" ? "error" : displayStatus(base, attention),
311 ...(attention !== undefined ? { attention } : {}),
312 agentId: agent.agentId,
313 blockedBy: [],
314 ...(after.has(agent.id) ? { after: after.get(agent.id)! } : {}),
315 ...(agent.lastError !== undefined ? { lastError: agent.lastError } : {}),
316 ...(agent.parent !== undefined && known.has(agent.parent) ? { parent: agent.parent } : {})
317 });
318 }
319 // A prompt no step stands for is a node of its own: done once its request
320 // is over, and the current turn while it is the latest.
321 for (const prompt of prompts) {
322 const follows = after.get(prompt.id);
323 const current = prompt.request === state.request;
324 out.push({
325 ...(current ? turnFields(state) : { status: "done" as const }),
326 id: prompt.id,
327 kind: "turn",
328 title: prompt.title,
329 ...(prompt.summary !== undefined && !(current && state.turn.running) ? { summary: prompt.summary } : {}),
330 ...(prompt.startedT !== undefined ? { startedAt: prompt.startedT } : {}),
331 ...(prompt.finishedT !== undefined && !(current && state.turn.running) ? { finishedAt: prompt.finishedT } : {}),
332 blockedBy: [],
333 ...(follows !== undefined ? { after: follows } : {})
334 });
335 }
336 // The turn node stands for the agent's work when no step does and the
337 // current request has no prompt node: before the first prompt, until a new
338 // request's plan arrives (hanging after the previous request's ends), and
339 // whenever something needs the person while no step is in progress.
340 const main = [...state.entries.values()].filter((entry) => entry.agentId === undefined);
341 const currentPlanned = main.some((entry) => entry.request === state.request);
342 const stepRunning = main.some((entry) => entry.base === "in_progress");
343 const promptShown = prompts.some((prompt) => prompt.request === state.request);
344 const showTurn =
345 state.turn.n > 0 &&
346 !promptShown &&
347 (out.length === 0 || (state.turn.running && !currentPlanned) || (state.turn.attention !== undefined && !stepRunning));
348 if (showTurn) {
349 const ends = out.length > 0 ? lastEnds(steps(state, known)) : [];
350 out.push({
351 ...turnFields(state),
352 id: `turn:${state.turn.n}`,
353 kind: "turn",
354 ...(ends.length > 0 ? { after: ends } : {}),
355 title: "Current turn",
356 ...(state.turn.summary !== undefined ? { summary: state.turn.summary } : {}),
357 ...(state.turn.startedT !== undefined ? { startedAt: state.turn.startedT } : {}),
358 ...(state.turn.finishedT !== undefined ? { finishedAt: state.turn.finishedT } : {}),
359 blockedBy: []
360 });
361 }
362 return out;
363}
364
365/** What the current turn shows: its status, why it needs the person, its last error. */
366function turnFields(state: GraphState): Pick<GraphNode, "status" | "attention" | "lastError"> {
367 return {
368 // A turn that ended on errors stays red until the next turn starts.
369 status:
370 state.turn.attention?.kind === "error"
371 ? "error"
372 : displayStatus(state.turn.running ? "in_progress" : "completed", state.turn.attention),
373 ...(state.turn.attention !== undefined ? { attention: state.turn.attention } : {}),
374 ...(state.turn.lastError !== undefined ? { lastError: state.turn.lastError } : {})
375 };
376}
377
378/**
379 * The prompts no plan step or subagent stands for, once their turn started
380 * (a replayed one has finished). A request with steps is drawn by its steps.
381 */
382function loosePrompts(state: GraphState): PromptEntry[] {
383 const covered = new Set<number>();
384 for (const entry of state.entries.values()) {
385 if (entry.agentId === undefined) covered.add(entry.request);
386 }
387 for (const agent of state.agents.values()) covered.add(agent.request);
388 return [...state.prompts.values()]
389 .filter((prompt) => prompt.startedT !== undefined && !covered.has(prompt.request))
390 .sort((a, b) => a.request - b.request);
391}
392
393export function sessionState(state: GraphState): SessionState {
394 if (state.ended) {
395 return "ended";
396 }
397 const shown = nodes(state);
398 // Only a real request (a permission, a question) makes the session wait for
399 // the person. Claude idling for the next prompt (Notification idle_prompt)
400 // is a resting session: other sessions may take the view, and the overlay
401 // may hide after a while.
402 if (shown.some((node) => node.status === "needs_you")) {
403 return "waiting_you";
404 }
405 if (state.turn.running || [...state.agents.values()].some((agent) => agent.state === "running")) {
406 return "working";
407 }
408 return "idle";
409}
410
411/** The person's own prompts; recordings from before the origin was kept count as theirs. */
412const PERSON_ORIGINS = new Set(["composer", "bridge", "sdk", "channel"]);
413
414function isPersonPrompt(origin: unknown): boolean {
415 return typeof origin !== "string" || PERSON_ORIGINS.has(origin);
416}
417
418/** A node in the session's timeline: a main-loop step, or a subagent under no step. */
419interface Step {
420 id: string;
421 request: number;
422 blockedBy: readonly string[];
423 createdAt: number;
424}
425
426/**
427 * The timeline's steps: the main loop's plan entries, the subagents that
428 * hang under no step (spawned without a plan), which stand for their request
429 * the way its steps would, and the prompts nothing else stands for. A
430 * subagent under a step follows that step instead.
431 */
432function steps(state: GraphState, known: ReadonlySet<string>): Step[] {
433 const main: Step[] = [...state.entries.values()].filter((entry) => entry.agentId === undefined);
434 const loose: Step[] = [...state.agents.values()]
435 .filter((agent) => agent.parent === undefined || !known.has(agent.parent))
436 .map((agent) => ({ id: agent.id, request: agent.request, blockedBy: [], createdAt: agent.createdAt }));
437 const prompts: Step[] = loosePrompts(state).map((prompt) => ({
438 id: prompt.id,
439 request: prompt.request,
440 blockedBy: [],
441 createdAt: prompt.createdAt
442 }));
443 return [...main, ...loose, ...prompts];
444}
445
446/** The steps that end the latest request with any: nothing in it depends on them. */
447function lastEnds(all: readonly Step[]): string[] {
448 if (all.length === 0) return [];
449 const latest = Math.max(...all.map((step) => step.request));
450 const group = all.filter((step) => step.request === latest);
451 const needed = new Set(group.flatMap((step) => step.blockedBy));
452 return group.filter((step) => !needed.has(step.id)).sort(byCreation).map((step) => step.id);
453}
454
455/**
456 * One session, one graph: the first steps of each request come after the last
457 * steps of the request before it, so the requests read left to right in the
458 * order they were made. Steps with no dependency inside their request start
459 * it; steps nothing in their request depends on end it.
460 */
461function sequenceLinks(state: GraphState, known: ReadonlySet<string>): Map<string, string[]> {
462 const byRequest = new Map<number, Step[]>();
463 for (const step of steps(state, known)) {
464 byRequest.set(step.request, [...(byRequest.get(step.request) ?? []), step]);
465 }
466 const links = new Map<string, string[]>();
467 let previous: Step[] | undefined;
468 for (const request of [...byRequest.keys()].sort((a, b) => a - b)) {
469 const entries = (byRequest.get(request) ?? []).sort(byCreation);
470 const ids = new Set(entries.map((entry) => entry.id));
471 if (previous !== undefined) {
472 const needed = new Set(previous.flatMap((entry) => entry.blockedBy));
473 const ends = previous.filter((entry) => !needed.has(entry.id)).map((entry) => entry.id);
474 for (const entry of entries) {
475 if (!entry.blockedBy.some((id) => ids.has(id))) links.set(entry.id, ends);
476 }
477 }
478 previous = entries;
479 }
480 return links;
481}
482
483// --- events -----------------------------------------------------------------
484
485function onToolCall(state: GraphState, event: GraphEvent, input: Record<string, unknown>, result: Record<string, unknown>): void {
486 const tool = text(input["tool"]) ?? "";
487 const loop = event.agentId ?? MAIN;
488 const isError = result["isError"] === true;
489 const denied = result["deny"] !== undefined;
490 const errorText = text(result["text"]);
491
492 // The call a permission prompt held has now run, or been refused.
493 clearAttention(state, (attention) => attention.kind !== "error", loop);
494 if (loop === MAIN) {
495 for (const entry of state.entries.values()) {
496 entry.paused = false;
497 }
498 }
499
500 state.asks.delete(askId(input, loop));
501 loopOf(state, loop).sawResult = true;
502 if (isError && !denied && !isStoppedByUser(errorText)) {
503 // Calls the model makes in parallel (three Reads of guessed paths) fail
504 // as one attempt: a batch counts once toward the streak.
505 const loopState = loopOf(state, loop);
506 if (!loopState.batchFailed) {
507 loopState.errorStreak += 1;
508 loopState.batchFailed = true;
509 }
510 // Every error flashes the node it happened on; a streak turns it red for good.
511 target(state, loop).lastError = { detail: errorDetail(errorText), at: event.t };
512 if (loopState.errorStreak >= state.errorThreshold) {
513 setAttention(state, loop, { kind: "error", detail: errorDetail(errorText), since: event.t });
514 }
515 } else if (!isError) {
516 loopOf(state, loop).errorStreak = 0;
517 clearAttention(state, (attention) => attention.kind === "error", loop);
518 }
519
520 if (isError || denied) {
521 return;
522 }
523 const payload = record(result["result"]);
524 switch (tool) {
525 case "TaskCreate":
526 onTaskCreate(state, event, input, payload);
527 break;
528 case "TaskUpdate":
529 onTaskUpdate(state, event, input, payload);
530 break;
531 case "TaskList":
532 onTaskList(state, event, payload);
533 break;
534 case "TaskGet":
535 if (isRecord(payload["task"])) {
536 upsertTask(state, event, payload["task"]);
537 }
538 break;
539 case FLOWPLAN_TOOL:
540 onFlowPlan(state, event, input);
541 break;
542 default:
543 break;
544 }
545}
546
547function onTaskCreate(state: GraphState, event: GraphEvent, input: Record<string, unknown>, payload: Record<string, unknown>): void {
548 const task = record(payload["task"]);
549 const taskId = text(task["id"]);
550 if (taskId === undefined) {
551 state.warnings.push(`TaskCreate without an id (seq ${event.seq ?? "?"})`);
552 return;
553 }
554 state.planSource = "tasks";
555 const id = `task:${taskId}`;
556 const activeForm = text(input["activeForm"]);
557 const description = text(input["description"]);
558 state.entries.set(id, {
559 id,
560 kind: "task",
561 title: text(task["subject"]) ?? text(input["subject"]) ?? id,
562 ...(activeForm !== undefined ? { activeForm } : {}),
563 ...(description !== undefined ? { description } : {}),
564 base: "pending",
565 ...(event.agentId !== undefined ? { agentId: event.agentId } : {}),
566 blockedBy: [],
567 paused: false,
568 createdAt: state.clock,
569 request: state.request
570 });
571}
572
573function onTaskUpdate(state: GraphState, event: GraphEvent, input: Record<string, unknown>, payload: Record<string, unknown>): void {
574 if (payload["success"] === false) {
575 return;
576 }
577 const taskId = text(input["taskId"]);
578 if (taskId === undefined) {
579 return;
580 }
581 const id = `task:${taskId}`;
582 const entry = state.entries.get(id);
583 if (entry === undefined) {
584 state.warnings.push(`TaskUpdate for unknown task ${taskId}`);
585 return;
586 }
587 const status = input["status"];
588 if (status === "deleted") {
589 removeEntry(state, id);
590 return;
591 }
592 if (status === "pending" || status === "in_progress" || status === "completed") {
593 setBase(state, entry, status, event.t);
594 }
595 entry.title = text(input["subject"]) ?? entry.title;
596 entry.activeForm = text(input["activeForm"]) ?? entry.activeForm;
597 entry.description = text(input["description"]) ?? entry.description;
598 for (const blocker of strings(input["addBlockedBy"])) {
599 addEdge(entry, `task:${blocker}`);
600 }
601 for (const blocked of strings(input["addBlocks"])) {
602 const target = state.entries.get(`task:${blocked}`);
603 if (target !== undefined) {
604 addEdge(target, id);
605 }
606 }
607}
608
609function onTaskList(state: GraphState, event: GraphEvent, payload: Record<string, unknown>): void {
610 if (!Array.isArray(payload["tasks"])) {
611 return;
612 }
613 state.planSource = "tasks";
614 const listed = new Set<string>();
615 for (const task of payload["tasks"]) {
616 if (isRecord(task)) {
617 const id = upsertTask(state, event, task);
618 if (id !== undefined) {
619 listed.add(id);
620 }
621 }
622 }
623 // The list is the whole truth: tasks it no longer has were deleted.
624 for (const id of [...state.entries.keys()]) {
625 if (id.startsWith("task:") && !listed.has(id)) {
626 removeEntry(state, id);
627 }
628 }
629}
630
631/** Creates or corrects a task from a TaskList / TaskGet record. */
632function upsertTask(state: GraphState, event: GraphEvent, task: Record<string, unknown>): string | undefined {
633 const taskId = text(task["id"]);
634 if (taskId === undefined) {
635 return undefined;
636 }
637 const id = `task:${taskId}`;
638 let entry = state.entries.get(id);
639 if (entry === undefined) {
640 entry = { id, kind: "task", title: id, base: "pending", blockedBy: [], paused: false, createdAt: state.clock, request: state.request };
641 state.entries.set(id, entry);
642 }
643 entry.title = text(task["subject"]) ?? entry.title;
644 const status = task["status"];
645 if (status === "pending" || status === "in_progress" || status === "completed") {
646 setBase(state, entry, status, event.t);
647 }
648 if (Array.isArray(task["blockedBy"])) {
649 entry.blockedBy = strings(task["blockedBy"]).map((blocker) => `task:${blocker}`);
650 }
651 return id;
652}
653
654/**
655 * FlowPlan: `{ steps: [{ id, title, blockedBy?, status? }] }`, each step
656 * created or updated by id. Steps left out are kept.
657 */
658function onFlowPlan(state: GraphState, event: GraphEvent, input: Record<string, unknown>): void {
659 if (!Array.isArray(input["steps"])) {
660 return;
661 }
662 state.planSource = "flowplan";
663 for (const step of input["steps"]) {
664 if (!isRecord(step)) {
665 continue;
666 }
667 const stepId = text(step["id"]);
668 if (stepId === undefined) {
669 continue;
670 }
671 // A step finished in an earlier request and planned again (to run, or with
672 // new dependencies) is new work: "run the tests" again. It gets its own
673 // node instead of pulling the old one forward, which would tie the
674 // requests in a loop.
675 let id = state.steps.get(stepId) ?? `plan:${stepId}`;
676 const earlier = state.entries.get(id);
677 const replanned =
678 earlier !== undefined &&
679 earlier.request < state.request &&
680 earlier.base === "completed" &&
681 (step["status"] === "pending" || step["status"] === "in_progress" || Array.isArray(step["blockedBy"]));
682 if (replanned) {
683 id = `plan:${stepId}#${state.request}`;
684 }
685 state.steps.set(stepId, id);
686 let entry = state.entries.get(id);
687 if (entry === undefined) {
688 entry = {
689 id,
690 kind: "plan",
691 title: id,
692 base: "pending",
693 ...(event.agentId !== undefined ? { agentId: event.agentId } : {}),
694 blockedBy: [],
695 paused: false,
696 createdAt: state.clock,
697 request: state.request
698 };
699 state.entries.set(id, entry);
700 }
701 entry.title = text(step["title"]) ?? entry.title;
702 entry.description = text(step["description"]) ?? entry.description;
703 const status = step["status"];
704 if (status === "pending" || status === "in_progress" || status === "completed") {
705 setBase(state, entry, status, event.t);
706 }
707 if (Array.isArray(step["blockedBy"])) {
708 entry.blockedBy = strings(step["blockedBy"]).map((blocker) => state.steps.get(blocker) ?? `plan:${blocker}`);
709 }
710 }
711}
712
713function onAgentSpawn(state: GraphState, event: GraphEvent, input: Record<string, unknown>, result: Record<string, unknown>): void {
714 const agentId = text(result["agentId"]);
715 if (agentId === undefined) {
716 return;
717 }
718 const title = text(input["description"]) ?? "subagent";
719 const parent = parentFor(state, title);
720 state.agents.set(agentId, {
721 id: `agent:${agentId}`,
722 agentId,
723 title,
724 state: "running",
725 startedT: event.t,
726 ...(parent !== undefined ? { parent } : {}),
727 createdAt: state.clock,
728 request: state.request
729 });
730}
731
732function onTurnComplete(
733 state: GraphState,
734 event: GraphEvent,
735 input: Record<string, unknown>,
736 result: Record<string, unknown>
737): void {
738 const agentId = event.agentId;
739 // The engine's own list is the authority on subagents.
740 if (Array.isArray(result["agents"])) {
741 for (const info of result["agents"]) {
742 const agent = isRecord(info) ? state.agents.get(text(info["id"]) ?? "") : undefined;
743 if (agent === undefined) {
744 continue;
745 }
746 if (info["status"] === "completed" && agent.state === "running") {
747 agent.state = "done";
748 agent.finishedT = event.t;
749 } else if ((info["status"] === "failed" || info["status"] === "killed") && agent.state === "running") {
750 agent.state = "failed";
751 agent.finishedT = event.t;
752 }
753 }
754 }
755
756 const summary = text(input["summary"]);
757 if (agentId !== undefined) {
758 const agent = state.agents.get(agentId);
759 if (agent !== undefined) {
760 if (agent.state === "running") {
761 agent.state = "done";
762 agent.finishedT = event.t;
763 }
764 agent.summary = summary ?? agent.summary;
765 }
766 skipUnstarted(state, agentId);
767 return;
768 }
769
770 state.turn.running = false;
771 state.turn.finishedT = event.t;
772 if (summary !== undefined) {
773 state.turn.summary = summary;
774 }
775 const prompt = state.prompts.get(state.request);
776 if (prompt !== undefined) {
777 prompt.finishedT = event.t;
778 prompt.summary = summary ?? prompt.summary;
779 }
780 // The main loop stopped: a step it left in_progress is not running any
781 // more, whether the turn was aborted with Esc or ended without the agent
782 // updating its plan. It runs again with the loop's next tool call.
783 for (const entry of state.entries.values()) {
784 if (entry.base === "in_progress" && entry.agentId === undefined) {
785 entry.paused = true;
786 }
787 }
788 skipUnstarted(state, undefined);
789 // Whatever the main loop had asked about is settled once its turn ends.
790 for (const [id, ask] of state.asks) {
791 if (ask.loop === MAIN) state.asks.delete(id);
792 }
793 const reason = input["reason"];
794 if (reason === "aborted") {
795 // Esc: what was running stops, it did not fail.
796 clearAttention(state, () => true, MAIN);
797 } else if (reason === "error" || reason === "refusal") {
798 setAttention(state, MAIN, { kind: "error", detail: `turn ended: ${String(reason)}`, since: event.t });
799 } else {
800 clearAttention(state, (attention) => attention.kind !== "error", MAIN);
801 }
802}
803
804function onPersonPrompt(state: GraphState, event: GraphEvent, input: Record<string, unknown>): void {
805 // Typed while a turn runs, it reaches the model inside that turn: the
806 // request before it is over, and this one is already under way.
807 const before = state.prompts.get(state.request);
808 if (state.turn.running && before !== undefined && before.finishedT === undefined) before.finishedT = event.t;
809 state.request += 1;
810 // Recordings from before prompts had titles keep the single turn node.
811 const title = text(input["title"]);
812 if (title === undefined) return;
813 const prompt: PromptEntry = { id: `prompt:${state.request}`, request: state.request, title, createdAt: state.clock };
814 if (state.turn.running) prompt.startedT = event.t;
815 // Replayed from the transcript: that request was over before the mod started.
816 if (input["replayed"] === true) {
817 prompt.startedT = event.t;
818 prompt.finishedT = event.t;
819 }
820 state.prompts.set(state.request, prompt);
821}
822
823function onPermissionRequest(state: GraphState, input: Record<string, unknown>, t: number): void {
824 const tool = text(input["tool_name"]) ?? "tool";
825 const asked = state.askedBy.get(tool);
826 setAttention(state, asked?.loop ?? MAIN, {
827 kind: tool === "AskUserQuestion" ? "question" : "permission",
828 detail: tool,
829 ...(asked?.reason !== undefined ? { reason: asked.reason } : {}),
830 since: t
831 });
832}
833
834// --- helpers ----------------------------------------------------------------
835
836function reset(state: GraphState): void {
837 const fresh = initialState({ errorThreshold: state.errorThreshold });
838 Object.assign(state, fresh, { cwd: state.cwd, clock: state.clock });
839}
840
841/** The node a loop is working on: its latest in_progress entry, its agent node, or the turn. */
842function target(state: GraphState, loop: string): PlanEntry | AgentEntry | GraphState["turn"] {
843 let current: PlanEntry | undefined;
844 for (const entry of state.entries.values()) {
845 const owner = entry.agentId ?? MAIN;
846 if (owner === loop && entry.base === "in_progress" && (current === undefined || (entry.startedAt ?? 0) >= (current.startedAt ?? 0))) {
847 current = entry;
848 }
849 }
850 if (current !== undefined) {
851 return current;
852 }
853 const agent = loop === MAIN ? undefined : state.agents.get(loop);
854 return agent ?? state.turn;
855}
856
857function setAttention(state: GraphState, loop: string, attention: Attention): void {
858 target(state, loop).attention = attention;
859}
860
861/**
862 * Clears matching attention. With `loop`, only on nodes that loop owns: its
863 * entries, its agent node (an agent's own id names its loop), or the turn
864 * for the main loop.
865 */
866function clearAttention(state: GraphState, match: (attention: Attention) => boolean, loop?: string): void {
867 const holders: Array<{ attention?: Attention; agentId?: string }> = [
868 ...state.entries.values(),
869 ...state.agents.values()
870 ];
871 for (const holder of holders) {
872 if (holder.attention !== undefined && match(holder.attention) && (loop === undefined || (holder.agentId ?? MAIN) === loop)) {
873 delete holder.attention;
874 }
875 }
876 if (state.turn.attention !== undefined && match(state.turn.attention) && (loop === undefined || loop === MAIN)) {
877 delete state.turn.attention;
878 }
879}
880
881/**
882 * A loop's turn ended: the steps of its plan it never started were left
883 * undone. They stay in the graph, as skipped, until the agent starts them or
884 * declares them again.
885 */
886function skipUnstarted(state: GraphState, agentId: string | undefined): void {
887 for (const entry of state.entries.values()) {
888 if (entry.base === "pending" && entry.startedT === undefined && entry.agentId === agentId) {
889 entry.skipped = true;
890 }
891 }
892}
893
894/** A call's id for `asks`: its tool_use_id, else its loop and tool. */
895function askId(input: Record<string, unknown>, loop: string): string {
896 return text(input["tool_use_id"]) ?? `${loop}:${text(input["tool"]) ?? ""}`;
897}
898
899function setBase(state: GraphState, entry: PlanEntry, base: BaseStatus, t: number): void {
900 if (base === "in_progress" && entry.base !== "in_progress") {
901 entry.startedAt = state.clock;
902 entry.startedT = t;
903 }
904 if (base !== "pending" && entry.base !== base) {
905 // A step only starts (or finishes) once what it waits on is done. A
906 // blocker the agent left "in progress" (it moved on without marking it)
907 // is done by now.
908 for (const id of entry.blockedBy) {
909 const blocker = state.entries.get(id);
910 if (blocker !== undefined && blocker.base === "in_progress") {
911 setBase(state, blocker, "completed", t);
912 }
913 }
914 // Work on a later request means the main agent moved on: a step of an
915 // earlier one it left "in progress" (never marked) is done by now too,
916 // or it would turn blue again with every call of every later request.
917 if (entry.agentId === undefined) {
918 for (const other of state.entries.values()) {
919 if (other.agentId === undefined && other.base === "in_progress" && other.request < entry.request) {
920 setBase(state, other, "completed", t);
921 }
922 }
923 }
924 }
925 if (base === "completed" && entry.base !== "completed") {
926 entry.finishedT = t;
927 }
928 entry.base = base;
929 entry.paused = false;
930 delete entry.skipped;
931 if (base === "completed") {
932 delete entry.attention;
933 }
934}
935
936function removeEntry(state: GraphState, id: string): void {
937 state.entries.delete(id);
938 for (const entry of state.entries.values()) {
939 entry.blockedBy = entry.blockedBy.filter((blocker) => blocker !== id);
940 }
941}
942
943function addEdge(entry: PlanEntry, blocker: string): void {
944 if (blocker !== entry.id && !entry.blockedBy.includes(blocker)) {
945 entry.blockedBy.push(blocker);
946 }
947}
948
949/**
950 * The task a new subagent works for, among the main loop's in-progress
951 * entries: the one whose title shares the most words with the agent's
952 * description ("Testes node:test para cart" → "Testes do cart"); on a tie, or
953 * with no word in common, the one with the fewest subagents so far, then the
954 * one started last.
955 */
956function parentFor(state: GraphState, description: string): string | undefined {
957 const wanted = words(description);
958 const helpers = new Map<string, number>();
959 for (const agent of state.agents.values()) {
960 if (agent.parent !== undefined) helpers.set(agent.parent, (helpers.get(agent.parent) ?? 0) + 1);
961 }
962 let best: { entry: PlanEntry; score: number } | undefined;
963 for (const entry of state.entries.values()) {
964 if (entry.agentId !== undefined || entry.base !== "in_progress") continue;
965 const own = words(`${entry.title} ${entry.description ?? ""}`);
966 const score = [...wanted].filter((word) => own.has(word)).length;
967 if (best === undefined || isBetterParent(entry, score, best, helpers)) best = { entry, score };
968 }
969 return best?.entry.id;
970}
971
972function isBetterParent(
973 entry: PlanEntry,
974 score: number,
975 best: { entry: PlanEntry; score: number },
976 helpers: ReadonlyMap<string, number>
977): boolean {
978 if (score !== best.score) return score > best.score;
979 const load = helpers.get(entry.id) ?? 0;
980 const bestLoad = helpers.get(best.entry.id) ?? 0;
981 if (load !== bestLoad) return load < bestLoad;
982 return (entry.startedAt ?? 0) >= (best.entry.startedAt ?? 0);
983}
984
985// Words too common in step titles to tell steps apart.
986const FILLER = new Set(["the", "and", "for", "with", "from", "into", "para", "com", "dos", "das", "uma", "por", "que", "test", "tests", "node"]);
987
988function words(text: string): Set<string> {
989 return new Set(
990 text
991 .toLowerCase()
992 .split(/[^\p{L}\p{N}]+/u)
993 .filter((word) => word.length >= 3 && !FILLER.has(word))
994 );
995}
996
997function loopOf(state: GraphState, loop: string): LoopState {
998 let found = state.loops.get(loop);
999 if (found === undefined) {
1000 found = { errorStreak: 0, batchFailed: false };
1001 state.loops.set(loop, found);
1002 }
1003 return found;
1004}
1005
1006function byCreation(left: { createdAt: number }, right: { createdAt: number }): number {
1007 return left.createdAt - right.createdAt;
1008}
1009
1010function record(value: unknown): Record<string, unknown> {
1011 return isRecord(value) ? value : {};
1012}
1013
1014function isRecord(value: unknown): value is Record<string, unknown> {
1015 return value !== null && typeof value === "object" && !Array.isArray(value);
1016}
1017
1018function text(value: unknown): string | undefined {
1019 return typeof value === "string" ? value : undefined;
1020}
1021
1022function strings(value: unknown): string[] {
1023 return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
1024}
1025graph/snapshot.ts 37 lines1// Builds the Snapshot every surface draws: the reducer's nodes, placed by
2// the layout, with the session around them.
3
4import { layout } from "./layout.ts";
5import { nodes, sessionState, type GraphState } from "./reduce.ts";
6import { SNAPSHOT_SCHEMA, type Layout, type Snapshot, type TerminalInfo } from "./types.ts";
7
8export interface SnapshotMeta {
9 seq: number;
10 now: number;
11 /** The previous snapshot's layout, so nodes keep their places. */
12 previous?: Layout;
13 terminal?: TerminalInfo;
14}
15
16export function snapshot(state: GraphState, meta: SnapshotMeta): Snapshot {
17 const shown = nodes(state);
18 const placed = layout(shown, meta.previous);
19 const warnings = [
20 ...state.warnings,
21 ...placed.brokenEdges.map(([from, to]) => `dependency cycle: ignored ${from} → ${to}`)
22 ];
23 return {
24 schema: SNAPSHOT_SCHEMA,
25 seq: meta.seq,
26 sessionId: state.sessionId ?? "",
27 cwd: state.cwd ?? "",
28 updatedAt: meta.now,
29 sessionState: sessionState(state),
30 ...(meta.terminal !== undefined ? { terminal: meta.terminal } : {}),
31 planSource: state.planSource,
32 nodes: shown,
33 layout: placed.layout,
34 warnings
35 };
36}
37graph/types.ts 115 lines1// The graph's data model: what the reducer reads (GraphEvent) and what every
2// surface draws (Snapshot). Pure types, no imports.
3//
4// Snapshot is the contract between the mod, the viewer and the overlay. A
5// change to its shape bumps SNAPSHOT_SCHEMA and updates all three together;
6// a new optional field does not (readers ignore what they do not know).
7
8export const SNAPSHOT_SCHEMA = 1;
9
10/** One engine event, as the hooks module sees it and the recorder stores it. */
11export interface GraphEvent {
12 seq?: number;
13 t: number;
14 event: string;
15 agentId?: string;
16 input?: unknown;
17 result?: unknown;
18}
19
20/** The five colors. */
21export type NodeStatus = "pending" | "running" | "needs_you" | "error" | "done";
22
23export type NodeKind = "task" | "agent" | "turn";
24
25/** Why a node needs attention, laid over its own status. */
26export interface Attention {
27 kind: "permission" | "question" | "error";
28 /** Tool name, or the last error's first line. */
29 detail: string;
30 /** Why the person is needed, in words: the command's purpose, the question asked. */
31 reason?: string;
32 since: number;
33}
34
35export interface GraphNode {
36 /** `task:<id>`, `plan:<id>`, `agent:<agentId>` or `turn:<n>`. */
37 id: string;
38 kind: NodeKind;
39 title: string;
40 /** Present-tense label while running ("Running tests"). */
41 activeForm?: string;
42 /** What the step is about, as the agent described it. */
43 description?: string;
44 /** What came of it, once done (a subagent's or a turn's answer, cut short). */
45 summary?: string;
46 /** When it started running and when it finished (ms since epoch). */
47 startedAt?: number;
48 finishedAt?: number;
49 /** What the node shows. */
50 status: NodeStatus;
51 attention?: Attention;
52 /** The loop that owns the node; absent for the main agent. */
53 agentId?: string;
54 /** Node ids this node waits on: the graph's edges. */
55 blockedBy: string[];
56 /**
57 * Node ids this node simply came after: the last steps of the previous
58 * request in the same session. Drawn dashed; not a dependency.
59 */
60 after?: string[];
61 /** The latest tool error while this node was current: the viewer flashes red when it changes. */
62 lastError?: { detail: string; at: number };
63 /** For an agent node, the task its parent loop was running when it spawned. */
64 parent?: string;
65 /** Edges were inferred (creation order), not declared by the agent. */
66 inferred?: boolean;
67 /**
68 * A pending step its loop's turn ended without starting: planned, then left
69 * undone. Drawn faded and left out of the plan's progress count; a step the
70 * agent starts or declares again is not skipped any more.
71 */
72 skipped?: boolean;
73}
74
75/** Where a node sits: its column (rank, from the roots) and row within it. */
76export interface NodePlacement {
77 rank: number;
78 order: number;
79}
80
81export type Layout = Record<string, NodePlacement>;
82
83export type SessionState = "working" | "waiting_you" | "idle" | "ended";
84
85/** Where the session runs, so a click on a node can bring that terminal forward. */
86export interface TerminalInfo {
87 /** The terminal app's bundle id (com.apple.Terminal, com.googlecode.iterm2, ...). */
88 app?: string;
89 /** TERM_PROGRAM. */
90 program?: string;
91 /** The session's tty, e.g. ttys021 (Unix only; Windows has none). */
92 tty?: string;
93 /** The Claude Code process, so a reader can tell a live session from a dead one. */
94 pid?: number;
95 /** Terminal-specific ids, when set: ITERM_SESSION_ID, TERM_SESSION_ID, MAESTRI_TERMINAL_ID, WT_SESSION (Windows Terminal). */
96 ids?: Record<string, string>;
97}
98
99export interface Snapshot {
100 schema: typeof SNAPSHOT_SCHEMA;
101 /** Grows with every snapshot of the session; readers drop older ones. */
102 seq: number;
103 sessionId: string;
104 cwd: string;
105 updatedAt: number;
106 sessionState: SessionState;
107 terminal?: TerminalInfo;
108 /** Where the plan comes from in this session. */
109 planSource: "tasks" | "flowplan" | "none";
110 nodes: GraphNode[];
111 layout: Layout;
112 /** Inconsistencies seen (unknown ids, cycles). Never thrown. */
113 warnings: string[];
114}
115live/present.ts 139 lines1// What the live mod says in the terminal and offers the model, as pure
2// functions over snapshots: the status line, the toasts, the FlowPlan tool
3// and the optional nudge.
4
5import type { GraphNode, Snapshot } from "../graph/types.ts";
6
7/** The status line: progress, then what needs the person. */
8export function statusLine(snapshot: Snapshot): string | undefined {
9 const plan = snapshot.nodes.filter((node) => node.kind !== "turn");
10 if (plan.length === 0 && snapshot.sessionState !== "waiting_you") {
11 return undefined;
12 }
13 const parts: string[] = [];
14 if (plan.length > 0) {
15 parts.push(`◆ ${plan.filter((node) => node.status === "done").length}/${plan.length}`);
16 }
17 const waiting = snapshot.nodes.filter((node) => node.status === "needs_you").length;
18 if (waiting > 0) {
19 parts.push(`${waiting} waiting for you`);
20 } else if (snapshot.sessionState === "waiting_you") {
21 parts.push("waiting for you");
22 }
23 const failed = snapshot.nodes.filter((node) => node.status === "error").length;
24 if (failed > 0) {
25 parts.push(`${failed} failed`);
26 }
27 return parts.join(" · ");
28}
29
30/** One line per node that turned yellow or red since `before`. */
31export function alerts(before: Snapshot | undefined, after: Snapshot): string[] {
32 const was = new Map(before?.nodes.map((node) => [node.id, node.status]) ?? []);
33 const out: string[] = [];
34 for (const node of after.nodes) {
35 if (node.status === was.get(node.id)) {
36 continue;
37 }
38 if (node.status === "needs_you") {
39 out.push(`${label(node)} needs you${node.attention ? ` (${node.attention.detail})` : ""}`);
40 } else if (node.status === "error") {
41 out.push(`${label(node)} failed${node.attention ? `: ${node.attention.detail}` : ""}`);
42 }
43 }
44 return out;
45}
46
47function label(node: GraphNode): string {
48 return node.kind === "turn" ? "The agent" : `"${node.title}"`;
49}
50
51/**
52 * Why a tool call needs the person, in words, for the hover card: the
53 * question asked, a command's own description, or what it touches.
54 */
55export function describeRequest(tool: string, input: unknown): string {
56 const args = input !== null && typeof input === "object" ? (input as Record<string, unknown>) : {};
57 const str = (value: unknown) => (typeof value === "string" && value.trim() !== "" ? value.trim() : undefined);
58 const cut = (value: string) => (value.length > 160 ? `${value.slice(0, 159)}…` : value);
59
60 if (tool === "AskUserQuestion" && Array.isArray(args["questions"])) {
61 const first = args["questions"][0] as Record<string, unknown> | undefined;
62 const question = str(first?.["question"]);
63 if (question !== undefined) {
64 return cut(question);
65 }
66 }
67 const described = str(args["description"]);
68 if (described !== undefined) {
69 return cut(`${tool}: ${described}`);
70 }
71 // A file is named by its last two path parts: the folder and the name.
72 const file = str(args["file_path"]) ?? str(args["path"]);
73 const short = file?.split("/").filter(Boolean).slice(-2).join("/");
74 const target = str(args["command"]) ?? short ?? str(args["url"]);
75 return target !== undefined ? cut(`${tool}: ${target}`) : `${tool} wants to run`;
76}
77
78/** The first lines of an answer, for a node's summary. */
79export function summarize(answer: string | undefined): string | undefined {
80 const text = answer?.trim();
81 if (text === undefined || text === "") {
82 return undefined;
83 }
84 return text.length > 300 ? `${text.slice(0, 299)}…` : text;
85}
86
87/** A prompt's first line, cut short: the title of its node on the timeline. */
88export function promptTitle(prompt: string): string | undefined {
89 const line = prompt.split("\n").map((part) => part.trim()).find((part) => part !== "");
90 if (line === undefined) {
91 return undefined;
92 }
93 const flat = line.replace(/\s+/g, " ");
94 return flat.length > 80 ? `${flat.slice(0, 79)}…` : flat;
95}
96
97/** The name the engine gives the registered tool: mcp__<plugin>__<name>. */
98export const FLOWPLAN_NAME = "FlowPlan";
99
100/** Registered when the session has no task tools (docs/events.md §1). */
101export const FLOWPLAN_SPEC = {
102 name: FLOWPLAN_NAME,
103 description:
104 "Declare or update your plan as steps with dependencies, so the person can follow it as a live graph. " +
105 "Call it when work has 3 or more steps: once with all steps, then again whenever a step starts, finishes or a new one appears. " +
106 "Steps are matched by id; steps you leave out are kept. Steps that can run in parallel should not block each other. " +
107 "The person watches progress live: when you start a step, mark it in_progress; as soon as it is done, mark it completed, one update per step rather than all at the end.",
108 inputSchema: {
109 type: "object",
110 properties: {
111 steps: {
112 type: "array",
113 items: {
114 type: "object",
115 properties: {
116 id: { type: "string", description: "Short stable id, e.g. 'tests'" },
117 title: { type: "string", description: "What the step does, in a few words" },
118 description: { type: "string", description: "One sentence on what the step involves" },
119 status: { type: "string", enum: ["pending", "in_progress", "completed"] },
120 blockedBy: { type: "array", items: { type: "string" }, description: "Ids of steps that must finish first" }
121 },
122 required: ["id"]
123 }
124 }
125 },
126 required: ["steps"]
127 }
128} as const;
129
130/** The nudge, added to the system prompt only when the person turns it on. */
131export function nudgeText(tool: string): string {
132 return (
133 `When a request needs 3 or more steps, declare your plan with ${tool} before starting: ` +
134 "one entry per step, with its dependencies (steps that can run in parallel do not block each other). " +
135 "Then update it step by step as you work: in_progress when you start a step, completed right after it is done. " +
136 "The person follows this plan as a live graph. Skip it for quick, single-step requests."
137 );
138}
139live/install.ts 328 lines1// `/flowctl init`: installs the overlay app the mod talks to. It downloads
2// the app of the mod's own version from the GitHub Release that CI publishes
3// (.github/workflows/release.yml), checks its SHA-256, and puts it where an
4// older copy was, else in ~/Applications (macOS) or %LOCALAPPDATA%\Programs
5// (Windows). Every step is a host command run through `run`, so the whole
6// flow is tested without a network or either system.
7
8import type { Platform } from "./platform.ts";
9import * as windows from "./windows.ts";
10
11/** The mod's version; the release CI refuses a tag that differs from plugin.json's. */
12export const MOD_VERSION = "1.0.6";
13export const REPO = "MarcoFilho1/FlowCTL";
14export const APP_NAME = "FlowCTL.app";
15const APP_ID = "dev.flowctl.overlay";
16const APP_TITLE = "FlowCTL";
17const LSREGISTER =
18 "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
19
20export interface RunResult {
21 exitCode: number;
22 stdout: string;
23 stderr: string;
24}
25export type Run = (argv: readonly string[], timeoutMs?: number) => Promise<RunResult>;
26
27export interface InstallContext {
28 run: Run;
29 home: string;
30 platform: Platform;
31 /** Windows folders: %LOCALAPPDATA% (the app's folder), %APPDATA% (Start menu), %TEMP% (downloads). */
32 localAppData?: string;
33 appData?: string;
34 temp?: string;
35 version?: string;
36 /**
37 * Where the release files are instead of GitHub, `<base>/v<version>/<file>`:
38 * FLOWCTL_RELEASE_BASE, to try a build before it is tagged.
39 */
40 releaseBase?: string;
41 /** Progress, one short line at a time (the status line). */
42 progress?: (line: string) => void;
43}
44
45/** What finding and checking the app needs. */
46type AppHost = Pick<InstallContext, "run" | "home" | "platform" | "localAppData">;
47
48export function zipName(version: string, platform: Platform): string {
49 return platform === "win32" ? windows.winZipName(version) : `FlowCTL-${version}-macos.zip`;
50}
51
52export function releaseUrl(version: string, file: string, base = `https://github.com/${REPO}/releases/download`): string {
53 return `${base.replace(/\/+$/, "")}/v${version}/${file}`;
54}
55
56/** Where an installed app may be: the person's own folder first on macOS; the one install folder on Windows. */
57export function appPlaces(host: Pick<AppHost, "home" | "platform" | "localAppData">): string[] {
58 if (host.platform === "win32") return host.localAppData ? [windows.installedExe(host.localAppData)] : [];
59 if (host.platform === "darwin") return [`${host.home}/Applications/${APP_NAME}`, `/Applications/${APP_NAME}`];
60 return [];
61}
62
63/** -1, 0 or 1 as `a` is older, the same or newer than `b` (x.y.z). */
64export function compareVersions(a: string, b: string): number {
65 const parts = (v: string) => v.split(".").map((n) => Number.parseInt(n, 10) || 0);
66 const [x, y] = [parts(a), parts(b)];
67 for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
68 const d = (x[i] ?? 0) - (y[i] ?? 0);
69 if (d !== 0) return d < 0 ? -1 : 1;
70 }
71 return 0;
72}
73
74/** The first word of a `shasum` line: the hex digest. */
75export function digestOf(line: string): string {
76 return line.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
77}
78
79export interface Installed {
80 path: string;
81 version: string;
82}
83
84/** The installed app and its version, if there is one. */
85export async function findApp(host: AppHost): Promise<Installed | undefined> {
86 for (const path of appPlaces(host)) {
87 const read = await host.run(
88 host.platform === "win32"
89 ? windows.versionOf(path)
90 : ["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", `${path}/Contents/Info.plist`]
91 );
92 if (read.exitCode === 0) return { path, version: read.stdout.trim() };
93 }
94 return undefined;
95}
96
97/** What a session start should say about the app, if anything. */
98export async function appNotice(ctx: AppHost & Pick<InstallContext, "version">): Promise<string | undefined> {
99 // Linux has no app yet: nothing to install or update.
100 if (ctx.platform === "other") return undefined;
101 const want = ctx.version ?? MOD_VERSION;
102 const app = await findApp(ctx);
103 if (!app) return "FlowCTL: the overlay app is not installed. Run /flowctl init to install it.";
104 if (compareVersions(app.version, want) < 0) return `FlowCTL: the overlay app is ${app.version}; run /flowctl init to update it to ${want}.`;
105 return undefined;
106}
107
108/**
109 * A session start with an app older than this mod: updates it, the way
110 * `/flowctl init` does, so updating the plugin is all it takes. Returns the
111 * line to show, or nothing when there is nothing to do (no app, an up to date
112 * one, Linux) or another session is already updating it.
113 */
114export async function autoUpdate(ctx: InstallContext): Promise<string | undefined> {
115 if (ctx.platform === "other") return undefined;
116 const want = ctx.version ?? MOD_VERSION;
117 const app = await findApp(ctx);
118 if (!app || compareVersions(app.version, want) >= 0) return undefined;
119 // One update at a time: several sessions may start (or reload the plugin) together.
120 const win = ctx.platform === "win32";
121 const lock = win ? `${ctx.home}\\.flowctl\\updating` : `${ctx.home}/.flowctl/updating`;
122 const taken = await ctx.run(
123 win ? windows.takeLock(lock) : ["sh", "-c", 'find "$1" -maxdepth 0 -mmin +10 -exec rm -rf {} + 2>/dev/null; mkdir "$1"', "sh", lock]
124 );
125 if (taken.exitCode !== 0) return undefined;
126 try {
127 ctx.progress?.(`FlowCTL: updating the overlay app from ${app.version} to ${want}…`);
128 const text = await init(ctx);
129 return text.startsWith("FlowCTL could not be installed")
130 ? `FlowCTL: the overlay app is ${app.version} and could not be updated to ${want} (${text.replace(/^FlowCTL could not be installed: /, "").replace(/\. Try again.*$/, "")}). Run /flowctl init to try again.`
131 : `FlowCTL: the overlay app was updated from ${app.version} to ${want}.`;
132 } finally {
133 await ctx.run(win ? windows.removeTree(lock) : ["rm", "-rf", lock]);
134 }
135}
136
137/**
138 * A newer version of this plugin among the folder names next to it (the
139 * plugin cache keeps one folder per version): the one to announce, if any.
140 */
141export function newerVersion(names: readonly string[], current = MOD_VERSION): string | undefined {
142 const versions = names.filter((name) => /^\d+\.\d+\.\d+$/.test(name)).sort(compareVersions);
143 const newest = versions.at(-1);
144 return newest !== undefined && compareVersions(newest, current) > 0 ? newest : undefined;
145}
146
147class Failure extends Error {}
148
149async function must(ctx: InstallContext, argv: readonly string[], what: string, timeoutMs?: number): Promise<RunResult> {
150 const result = await ctx.run(argv, timeoutMs);
151 if (result.exitCode !== 0) {
152 const why = (result.stderr || result.stdout).trim().split("\n")[0] ?? "";
153 throw new Failure(`${what} failed${why ? `: ${why}` : ""}`);
154 }
155 return result;
156}
157
158async function quitApp(ctx: InstallContext): Promise<void> {
159 if (ctx.platform === "win32") {
160 await ctx.run(windows.KILL_APP);
161 return;
162 }
163 await ctx.run(["osascript", "-e", `tell application id "${APP_ID}" to quit`]);
164 for (let i = 0; i < 10; i += 1) {
165 if ((await ctx.run(["pgrep", "-x", "FlowCTL"])).exitCode !== 0) return;
166 await ctx.run(["sleep", "0.5"]);
167 }
168 await ctx.run(["pkill", "-x", "FlowCTL"]);
169}
170
171/** The command that opens the app at `path` in the foreground. */
172function openArgs(ctx: InstallContext, path: string): readonly string[] {
173 return ctx.platform === "win32" ? windows.startProcess(path) : ["open", path];
174}
175
176/** Opens the installed app in the background, when it is not listening (session start). */
177export async function openInBackground(ctx: InstallContext): Promise<void> {
178 if (ctx.platform === "win32") {
179 const app = await findApp(ctx);
180 if (app) await ctx.run(windows.startProcess(app.path));
181 return;
182 }
183 await ctx.run(["open", "-g", "-a", APP_TITLE]);
184}
185
186/** Puts the app at `source` in place of any installed copy, registers and opens it. */
187async function place(ctx: InstallContext, source: string, existing: Installed | undefined): Promise<string> {
188 if (ctx.platform === "win32") return placeWindows(ctx, source);
189 const target = existing?.path ?? appPlaces(ctx)[0]!;
190 await quitApp(ctx);
191 await must(ctx, ["mkdir", "-p", target.slice(0, target.lastIndexOf("/"))], "Creating the Applications folder");
192 await must(ctx, ["rm", "-rf", target], `Removing the old ${target}`);
193 await must(ctx, ["ditto", source, target], `Copying the app to ${target}`);
194 await ctx.run([LSREGISTER, "-f", target]);
195 await ctx.run(openArgs(ctx, target));
196 return target;
197}
198
199/** Windows: the exe into its folder, a Start menu shortcut (best effort), then opened. */
200async function placeWindows(ctx: InstallContext, source: string): Promise<string> {
201 const localAppData = ctx.localAppData ?? "";
202 const exe = windows.installedExe(localAppData);
203 await quitApp(ctx);
204 await must(ctx, windows.makeDir(windows.installDir(localAppData)), "Creating the install folder");
205 await must(ctx, windows.copyFile(source, exe), `Copying the app to ${exe}`);
206 if (ctx.appData) {
207 await ctx.run(windows.shortcut(windows.startMenuShortcut(ctx.appData), exe, windows.installDir(localAppData)));
208 }
209 await ctx.run(openArgs(ctx, exe));
210 return exe;
211}
212
213export interface InitOptions {
214 /** Install again even when this version is there. */
215 force?: boolean;
216 /** Build from the tagged source instead of downloading (needs pnpm, Node 22, Swift). */
217 fromSource?: boolean;
218}
219
220/** `/flowctl init`: the line to show the person. Never throws. */
221export async function init(ctx: InstallContext, options: InitOptions = {}): Promise<string> {
222 const version = ctx.version ?? MOD_VERSION;
223 if (ctx.platform === "other") {
224 return "FlowCTL's overlay does not run on Linux yet. The graph still shows in the status line.";
225 }
226 if (ctx.platform === "win32" && options.fromSource) {
227 return "FlowCTL's --from-source is not supported on Windows yet. Run /flowctl init to install the release.";
228 }
229 try {
230 if (ctx.platform === "win32" && (!ctx.localAppData || !ctx.temp)) {
231 throw new Failure("%LOCALAPPDATA% and %TEMP% must be set");
232 }
233 const existing = await findApp(ctx);
234 if (existing && !options.force && compareVersions(existing.version, version) >= 0) {
235 await ctx.run(openArgs(ctx, existing.path));
236 return `FlowCTL ${existing.version} is already installed at ${existing.path}; opened it. (/flowctl init --force installs it again.)`;
237 }
238 const work = await makeWork(ctx);
239 try {
240 const app = options.fromSource ? await build(ctx, work, version) : await download(ctx, work, version);
241 ctx.progress?.("FlowCTL: installing…");
242 const target = await place(ctx, app, existing);
243 const was = existing ? ` (was ${existing.version})` : "";
244 return `FlowCTL ${version} installed at ${target}${was} and opened. It shows this session's graph from the next change on.`;
245 } finally {
246 await ctx.run(ctx.platform === "win32" ? windows.removeTree(work) : ["rm", "-rf", work]);
247 }
248 } catch (error) {
249 const message = error instanceof Failure ? error.message : String(error);
250 return `FlowCTL could not be installed: ${message}. Try again, or /flowctl init --from-source.`;
251 }
252}
253
254/** A new empty folder for the download: mktemp on macOS, a folder in %TEMP% on Windows. */
255async function makeWork(ctx: InstallContext): Promise<string> {
256 if (ctx.platform === "win32") {
257 const dir = `${ctx.temp}\\flowctl-${Math.random().toString(36).slice(2, 10)}`;
258 await must(ctx, windows.makeDir(dir), "Making a temporary folder");
259 return dir;
260 }
261 return (await must(ctx, ["mktemp", "-d", "-t", "flowctl"], "Making a temporary folder")).stdout.trim();
262}
263
264async function download(ctx: InstallContext, work: string, version: string): Promise<string> {
265 const win = ctx.platform === "win32";
266 const zip = zipName(version, ctx.platform);
267 const file = win ? `${work}\\${zip}` : `${work}/${zip}`;
268 const curl = win ? windows.CURL : "curl";
269 ctx.progress?.(`FlowCTL: downloading ${version}…`);
270 await must(ctx, [curl, "-fsSL", "--retry", "2", "-o", file, releaseUrl(version, zip, ctx.releaseBase)], `Downloading ${zip}`, 180_000);
271 await must(ctx, [curl, "-fsSL", "--retry", "2", "-o", `${file}.sha256`, releaseUrl(version, `${zip}.sha256`, ctx.releaseBase)], "Downloading its checksum", 60_000);
272 ctx.progress?.("FlowCTL: checking the download…");
273 const expected = digestOf((await must(ctx, win ? windows.readText(`${file}.sha256`) : ["cat", `${file}.sha256`], "Reading the checksum")).stdout);
274 const actual = digestOf((await must(ctx, win ? windows.hashOf(file) : ["shasum", "-a", "256", file], "Checking the download")).stdout);
275 if (expected.length !== 64 || actual !== expected) {
276 throw new Failure("the download does not match its published SHA-256");
277 }
278 if (win) {
279 await must(ctx, windows.makeDir(`${work}\\out`), "Making the unzip folder");
280 await must(ctx, [windows.TAR, "-xf", file, "-C", `${work}\\out`], "Unzipping the app");
281 return `${work}\\out\\${windows.WIN_EXE}`;
282 }
283 await must(ctx, ["ditto", "-x", "-k", file, `${work}/out`], "Unzipping the app");
284 const app = `${work}/out/${APP_NAME}`;
285 await must(ctx, ["codesign", "--verify", app], "Checking the app's signature");
286 return app;
287}
288
289async function build(ctx: InstallContext, work: string, version: string): Promise<string> {
290 ctx.progress?.(`FlowCTL: building ${version} from source (a few minutes)…`);
291 const src = `${work}/src`;
292 await must(ctx, ["git", "clone", "--depth", "1", "--branch", `v${version}`, `https://github.com/${REPO}.git`, src], "Cloning the source", 180_000);
293 await must(ctx, [`${src}/scripts/install-overlay.sh`, "--build-only"], "Building the app (needs pnpm, Node 22 and Swift)", 600_000);
294 return `${src}/apps/overlay/build/${APP_NAME}`;
295}
296
297/** `/flowctl uninstall`: quits and removes the app; ~/.flowctl stays. */
298export async function uninstall(ctx: InstallContext): Promise<string> {
299 const app = await findApp(ctx);
300 if (!app) return "FlowCTL's overlay app is not installed.";
301 await quitApp(ctx);
302 if (ctx.platform === "win32") {
303 const dir = app.path.slice(0, app.path.lastIndexOf("\\"));
304 const lnk = ctx.appData ? windows.startMenuShortcut(ctx.appData) : undefined;
305 const removed = await ctx.run(windows.removeInstall(dir, lnk));
306 if (removed.exitCode !== 0) return `Could not remove ${dir}: ${removed.stderr.trim()}`;
307 return `Removed ${dir} and its Start menu shortcut. Session files stay in ~/.flowctl; the plugin itself is removed with /plugin.`;
308 }
309 await ctx.run([LSREGISTER, "-u", app.path]);
310 const removed = await ctx.run(["rm", "-rf", app.path]);
311 if (removed.exitCode !== 0) return `Could not remove ${app.path}: ${removed.stderr.trim()}`;
312 return `Removed ${app.path}. Session files stay in ~/.flowctl; the plugin itself is removed with /plugin.`;
313}
314
315/** Parses `/flowctl <args>`. */
316export function parseArgs(args: string): { action: "init" | "uninstall" | "help"; options: InitOptions } {
317 const words = args.trim().split(/\s+/).filter(Boolean);
318 const action = words[0] === "init" || words[0] === "uninstall" ? words[0] : "help";
319 return { action, options: { force: words.includes("--force"), fromSource: words.includes("--from-source") } };
320}
321
322export const HELP = [
323 "/flowctl init install or update the overlay app (downloads the release of this plugin's version)",
324 "/flowctl init --force install it again",
325 "/flowctl init --from-source build it from the tagged source instead (macOS only; needs pnpm, Node 22 and Swift)",
326 "/flowctl uninstall remove the app"
327].join("\n");
328live/platform.ts 16 lines1// Which system the mod runs on, as far as it needs to know: macOS and
2// Windows have an overlay app; anything else (Linux, for now) does not.
3
4export type Platform = "darwin" | "win32" | "other";
5
6/** Windows sets OS to Windows_NT; elsewhere `uname -s` names the system. */
7export function platformOf(osVariable: string | undefined, uname: string): Platform {
8 if (osVariable === "Windows_NT") return "win32";
9 return uname.trim() === "Darwin" ? "darwin" : "other";
10}
11
12/** The person's home folder: HOME, else USERPROFILE (Windows). */
13export function homeOf(home: string | undefined, userProfile: string | undefined): string {
14 return home || userProfile || "";
15}
16live/rebuild.ts 100 lines1// Rebuilds a session's graph from its transcript, for when the mod starts
2// late: a reload of the mod, a resumed session, an update mid-session. The
3// transcript keeps every tool call and its result, so the plan (tasks,
4// FlowPlan steps, subagents) can be replayed; what was only momentary (a
5// permission prompt, an error streak) is not, and need not be.
6
7import type { GraphEvent } from "../graph/types.ts";
8import { promptTitle } from "./present.ts";
9
10/** The part of a transcript message the rebuild reads ($.session.messages()). */
11export interface TranscriptMessage {
12 role: "user" | "assistant";
13 /** The message's text blocks joined; a person's prompt has some. */
14 text?: string;
15 toolUses: ReadonlyArray<{ tool: string; input: Record<string, unknown>; result?: unknown; isError?: true; text?: string }>;
16 /** A user message carrying tool results is the engine's, not the person's. */
17 toolResults?: readonly unknown[];
18}
19
20/** The part of $.agent.list() the rebuild reads. */
21export interface AgentStatusRow {
22 id: string;
23 status: string;
24 description?: string;
25}
26
27const PLAN_TOOLS = new Set(["TaskCreate", "TaskUpdate", "TaskGet", "TaskList"]);
28
29/**
30 * The events that replay the plan the transcript holds, in order, stamped
31 * `at`. Agents the engine still lists get their live status; others are done.
32 */
33export function rebuildEvents(
34 messages: readonly TranscriptMessage[],
35 options: { flowPlanTool: string; agents?: readonly AgentStatusRow[]; at: number }
36): GraphEvent[] {
37 const events: GraphEvent[] = [];
38 const t = options.at;
39 const spawned: string[] = [];
40
41 for (const message of messages) {
42 // Each of the person's prompts starts a request, so the rebuilt graph
43 // keeps its requests apart, in order, as the live one does; one with no
44 // plan comes back as its own node, already answered.
45 if (isPersonPrompt(message)) {
46 const title = promptTitle(message.text!);
47 events.push({
48 t,
49 event: "prompt.submit",
50 input: { chars: message.text!.length, ...(title !== undefined ? { title } : {}), replayed: true }
51 });
52 continue;
53 }
54 if (message.role !== "assistant") continue;
55 for (const use of message.toolUses) {
56 // An unanswered call has not happened yet, and a failed one changed nothing.
57 if (use.result === undefined || use.isError) continue;
58 if (PLAN_TOOLS.has(use.tool) || use.tool === options.flowPlanTool) {
59 events.push({ t, event: "tool.call", input: { tool: use.tool, ...use.input }, result: { result: use.result } });
60 } else if (use.tool === "Agent") {
61 const agentId = agentIdOf(use.result);
62 if (agentId === undefined) continue;
63 spawned.push(agentId);
64 const description = typeof use.input["description"] === "string" ? use.input["description"] : undefined;
65 events.push({ t, event: "agent.spawn", input: description !== undefined ? { description } : {}, result: { agentId } });
66 }
67 }
68 }
69
70 if (spawned.length > 0) {
71 const listed = new Map((options.agents ?? []).map((row) => [row.id, row.status]));
72 // turn.complete carries the engine's agent list: running ones stay running.
73 events.push({
74 t,
75 event: "turn.complete",
76 agentId: "flowctl-rebuild",
77 input: { reason: "answer" },
78 result: { agents: spawned.map((id) => ({ id, status: listed.get(id) ?? "completed" })) }
79 });
80 }
81 return events;
82}
83
84/**
85 * A user message the person typed: text, no tool results, and none of the
86 * engine's own wrappers (task notifications, system reminders, a slash
87 * command's echo, an interruption note).
88 */
89function isPersonPrompt(message: TranscriptMessage): boolean {
90 if (message.role !== "user" || (message.toolResults?.length ?? 0) > 0) return false;
91 const text = message.text?.trim() ?? "";
92 return text !== "" && !text.startsWith("<") && !text.startsWith("[Request interrupted");
93}
94
95function agentIdOf(result: unknown): string | undefined {
96 if (result === null || typeof result !== "object") return undefined;
97 const id = (result as Record<string, unknown>)["agentId"];
98 return typeof id === "string" ? id : undefined;
99}
100live/transport.ts 103 lines1// How the mod reaches the overlay app. On macOS the app listens on a Unix
2// socket in ~/.flowctl. On Windows it listens on 127.0.0.1 on a port it
3// picks, and writes that port with a token to ~/.flowctl/overlay.json; every
4// request carries the token. The file is cached until its modification time
5// changes, and a failed request re-reads it: the app may have restarted on
6// another port.
7
8import type { Platform } from "./platform.ts";
9
10export interface OverlayInfo {
11 port: number;
12 token: string;
13 pid: number;
14 version: string;
15}
16
17export interface OverlayRequest {
18 method: string;
19 headers?: Record<string, string>;
20 body?: string;
21 socketPath?: string;
22}
23
24/** The host's HTTP and files, as a hook has them ($.http, $.fs). */
25export interface OverlayIo {
26 fetch: (url: string, init: OverlayRequest) => Promise<{ ok: boolean }>;
27 read: (path: string) => Promise<string>;
28 mtime: (path: string) => Promise<number>;
29}
30
31export interface OverlayClient {
32 /** One request to the app; true when it answered (on Windows, with a 2xx). */
33 request: (method: "GET" | "POST", path: string, body?: string) => Promise<boolean>;
34}
35
36/** What the app wrote to overlay.json, or undefined when it is not that. */
37export function parseOverlayInfo(text: string): OverlayInfo | undefined {
38 try {
39 const info = JSON.parse(text) as Partial<OverlayInfo>;
40 const valid = Number.isInteger(info.port) && (info.port ?? 0) > 0 && typeof info.token === "string" && info.token !== "";
41 return valid ? (info as OverlayInfo) : undefined;
42 } catch {
43 // Not JSON yet: the app is still writing it.
44 return undefined;
45 }
46}
47
48/** The client for one session's ~/.flowctl folder. */
49export function overlayClient(io: OverlayIo, dir: string, platform: Platform): OverlayClient {
50 const file = `${dir}/overlay.json`;
51 let cached: { mtime: number; info: OverlayInfo } | undefined;
52
53 // The app's port and token: the cache while the file keeps its mtime, else
54 // the file as it is now (`fresh` skips the cache).
55 async function infoOf(fresh: boolean): Promise<OverlayInfo | undefined> {
56 const mtime = await io.mtime(file).catch(() => undefined);
57 if (mtime === undefined) {
58 cached = undefined;
59 return undefined;
60 }
61 if (!fresh && cached?.mtime === mtime) return cached.info;
62 const info = parseOverlayInfo(await io.read(file).catch(() => ""));
63 cached = info === undefined ? undefined : { mtime, info };
64 return info;
65 }
66
67 function send(info: OverlayInfo, method: string, path: string, body: string | undefined): Promise<boolean> {
68 return io
69 .fetch(`http://127.0.0.1:${info.port}${path}`, {
70 method,
71 headers: { "X-FlowCTL-Token": info.token },
72 ...(body !== undefined ? { body } : {})
73 })
74 .then((response) => response.ok)
75 .catch(() => false);
76 }
77
78 async function sendWindows(method: string, path: string, body: string | undefined): Promise<boolean> {
79 const first = await infoOf(false);
80 if (first === undefined) return false;
81 if (await send(first, method, path, body)) return true;
82 // Retry only when the app wrote a new port or token: the same one failed.
83 const second = await infoOf(true);
84 if (second === undefined || (second.port === first.port && second.token === first.token)) return false;
85 return send(second, method, path, body);
86 }
87
88 return {
89 async request(method, path, body) {
90 if (platform !== "win32") {
91 // macOS: any answer counts, as the health check always did.
92 return io
93 .fetch(`http://overlay${path}`, { method, socketPath: `${dir}/overlay.sock`, ...(body !== undefined ? { body } : {}) })
94 .then(
95 () => true,
96 () => false
97 );
98 }
99 return sendWindows(method, path, body);
100 }
101 };
102}
103live/windows.ts 99 lines1// The Windows commands of the install flow, as argument vectors (no shell).
2// PowerShell does what the Mac tools do: a version, a hash, a copy, a
3// shortcut. Paths are written with backslashes, as a person sees them.
4
5export const WIN_EXE = "FlowCTL.exe";
6export const CURL = "curl.exe";
7export const TAR = "tar.exe";
8/** Closes the app; a failure (it is not running) is fine. */
9export const KILL_APP = ["taskkill", "/IM", WIN_EXE, "/F"];
10
11/** The release zip of a version: FlowCTL-X.Y.Z-windows-x64.zip. */
12export function winZipName(version: string): string {
13 return `FlowCTL-${version}-windows-x64.zip`;
14}
15
16/** A PowerShell single-quoted string: quotes doubled, so a path stays one argument. */
17export function psQuote(value: string): string {
18 return `'${value.replaceAll("'", "''")}'`;
19}
20
21/** One PowerShell command, with no profile and no prompts. */
22export function powershell(command: string): string[] {
23 return ["powershell", "-NoProfile", "-NonInteractive", "-Command", command];
24}
25
26/** Where the app is installed: %LOCALAPPDATA%\Programs\FlowCTL. */
27export function installDir(localAppData: string): string {
28 return `${localAppData}\\Programs\\FlowCTL`;
29}
30
31export function installedExe(localAppData: string): string {
32 return `${installDir(localAppData)}\\${WIN_EXE}`;
33}
34
35/** The Start menu shortcut, in %APPDATA%. */
36export function startMenuShortcut(appData: string): string {
37 return `${appData}\\Microsoft\\Windows\\Start Menu\\Programs\\FlowCTL.lnk`;
38}
39
40export function versionOf(exe: string): string[] {
41 return powershell(`(Get-Item -LiteralPath ${psQuote(exe)}).VersionInfo.ProductVersion`);
42}
43
44/** The SHA-256 hex digest of a file, upper case. */
45export function hashOf(file: string): string[] {
46 return powershell(`(Get-FileHash -Algorithm SHA256 -LiteralPath ${psQuote(file)}).Hash`);
47}
48
49export function readText(file: string): string[] {
50 return powershell(`Get-Content -Raw -LiteralPath ${psQuote(file)}`);
51}
52
53export function makeDir(dir: string): string[] {
54 return powershell(`New-Item -ItemType Directory -Force -Path ${psQuote(dir)} | Out-Null`);
55}
56
57/**
58 * Takes the update lock: makes `dir`, failing when it is there (another
59 * session is updating). A lock older than ten minutes was left by an update
60 * that never finished; it is taken over.
61 */
62export function takeLock(dir: string): string[] {
63 const d = psQuote(dir);
64 return powershell(
65 `if ((Test-Path -LiteralPath ${d}) -and ((Get-Item -LiteralPath ${d}).LastWriteTime -lt (Get-Date).AddMinutes(-10))) { Remove-Item -LiteralPath ${d} -Recurse -Force }; ` +
66 `New-Item -ItemType Directory -Path ${d} -ErrorAction Stop | Out-Null`
67 );
68}
69
70export function copyFile(from: string, to: string): string[] {
71 return powershell(`Copy-Item -LiteralPath ${psQuote(from)} -Destination ${psQuote(to)} -Force`);
72}
73
74export function shortcut(lnk: string, target: string, workDir: string): string[] {
75 return powershell(
76 `$s = (New-Object -ComObject WScript.Shell).CreateShortcut(${psQuote(lnk)}); ` +
77 `$s.TargetPath = ${psQuote(target)}; $s.WorkingDirectory = ${psQuote(workDir)}; $s.Save()`
78 );
79}
80
81export function startProcess(exe: string): string[] {
82 return powershell(`Start-Process -FilePath ${psQuote(exe)}`);
83}
84
85/** Removes the install folder, and the shortcut when there is one. */
86export function removeInstall(dir: string, lnk: string | undefined): string[] {
87 const shortcutPart = lnk === undefined ? "" : `; Remove-Item -LiteralPath ${psQuote(lnk)} -Force -ErrorAction SilentlyContinue`;
88 return powershell(`Remove-Item -LiteralPath ${psQuote(dir)} -Recurse -Force${shortcutPart}`);
89}
90
91export function removeTree(dir: string): string[] {
92 return removeInstall(dir, undefined);
93}
94
95/** The pid of the process that started this PowerShell: the Claude Code process. */
96export function parentPid(): string[] {
97 return powershell('(Get-CimInstance Win32_Process -Filter "ProcessId=$PID").ParentProcessId');
98}
99recorder/recording.ts 125 lines1// Phase 1 event recorder: pure bookkeeping, no engine calls, so node --test
2// covers it. The hooks module feeds it events and writes the files it names.
3
4export interface RecordedEvent {
5 seq: number;
6 t: number;
7 event: string;
8 agentId?: string;
9 input?: unknown;
10 result?: unknown;
11}
12
13export interface ChunkWrite {
14 file: string;
15 text: string;
16}
17
18const MAX_STRING = 2000;
19const MAX_DEPTH = 6;
20const MAX_ITEMS = 50;
21
22/**
23 * A copy of `value` that is safe to keep: long strings cut, deep or wide
24 * structures truncated, functions and other non-JSON values dropped. Tool
25 * outputs (file contents, command output) are the bulk of a session and are
26 * not what the graph needs.
27 */
28export function trim(value: unknown, depth = 0): unknown {
29 if (value === null || typeof value === "number" || typeof value === "boolean") {
30 return value;
31 }
32 if (typeof value === "string") {
33 return value.length > MAX_STRING
34 ? `${value.slice(0, MAX_STRING)}…[+${value.length - MAX_STRING} chars]`
35 : value;
36 }
37 if (typeof value !== "object" || value === undefined) {
38 return undefined;
39 }
40 if (depth >= MAX_DEPTH) {
41 return "…[depth]";
42 }
43 if (Array.isArray(value)) {
44 const items = value.slice(0, MAX_ITEMS).map((item) => trim(item, depth + 1));
45 return value.length > MAX_ITEMS ? [...items, `…[+${value.length - MAX_ITEMS} items]`] : items;
46 }
47 const out: Record<string, unknown> = {};
48 for (const [key, item] of Object.entries(value)) {
49 const kept = trim(item, depth + 1);
50 if (kept !== undefined) {
51 out[key] = kept;
52 }
53 }
54 return out;
55}
56
57/**
58 * A copy of `value` without the given keys, at any depth: classic events nest
59 * tool payloads (PostToolBatch's `tool_calls[].tool_input`).
60 */
61export function omitKeys(value: unknown, keys: ReadonlySet<string>): unknown {
62 if (Array.isArray(value)) {
63 return value.map((item) => omitKeys(item, keys));
64 }
65 if (value !== null && typeof value === "object") {
66 return Object.fromEntries(
67 Object.entries(value)
68 .filter(([key]) => !keys.has(key))
69 .map(([key, item]) => [key, omitKeys(item, keys)])
70 );
71 }
72 return value;
73}
74
75/**
76 * Events in numbered JSONL chunks (`rec-<run>-0001.jsonl`, ...), `run` naming
77 * this recording so a resumed session (same folder) never overwrites an
78 * earlier one's chunks. The engine's file
79 * API has no append, so each flush rewrites only the chunks touched since the
80 * last one: the cost of a flush stays bounded by the chunk size.
81 */
82export class Recording {
83 private readonly events: RecordedEvent[] = [];
84 private dirtyFrom: number | undefined;
85 private readonly directory: string;
86 private readonly run: string;
87 private readonly chunkSize: number;
88
89 constructor(directory: string, run: string, chunkSize = 200) {
90 this.directory = directory;
91 this.run = run;
92 this.chunkSize = chunkSize;
93 }
94
95 add(event: Omit<RecordedEvent, "seq">): RecordedEvent {
96 const recorded: RecordedEvent = { seq: this.events.length + 1, ...event };
97 this.events.push(recorded);
98 this.dirtyFrom ??= this.events.length - 1;
99 return recorded;
100 }
101
102 get size(): number {
103 return this.events.length;
104 }
105
106 /** The chunk files changed since the last call, whole. */
107 flush(): ChunkWrite[] {
108 if (this.dirtyFrom === undefined) {
109 return [];
110 }
111 const writes: ChunkWrite[] = [];
112 const first = Math.floor(this.dirtyFrom / this.chunkSize);
113 const last = Math.floor((this.events.length - 1) / this.chunkSize);
114 for (let chunk = first; chunk <= last; chunk += 1) {
115 const slice = this.events.slice(chunk * this.chunkSize, (chunk + 1) * this.chunkSize);
116 writes.push({
117 file: `${this.directory}/rec-${this.run}-${String(chunk + 1).padStart(4, "0")}.jsonl`,
118 text: slice.map((event) => JSON.stringify(event)).join("\n") + "\n"
119 });
120 }
121 this.dirtyFrom = undefined;
122 return writes;
123 }
124}
125graph/status.ts 53 lines1// Color rules: which of the five statuses a node shows, and which tool errors
2// count against it. Pure, no engine calls.
3
4import type { Attention, NodeStatus } from "./types.ts";
5
6/** A node's own progress, before attention is laid over it. */
7export type BaseStatus = "pending" | "in_progress" | "completed";
8
9/** Consecutive tool errors in one loop that turn its current node red. */
10export const DEFAULT_ERROR_THRESHOLD = 3;
11
12/**
13 * What a node shows. Done wins (a finished node needs nothing), then error,
14 * then needs-you, then its own progress. A paused node (the turn ended, or
15 * was aborted with Esc, while it ran) shows as pending: stopping is not failing.
16 */
17export function displayStatus(base: BaseStatus, attention?: Attention, paused = false): NodeStatus {
18 if (base === "completed") {
19 return "done";
20 }
21 if (attention?.kind === "error") {
22 return "error";
23 }
24 if (attention !== undefined) {
25 return "needs_you";
26 }
27 if (base === "in_progress") {
28 return paused ? "pending" : "running";
29 }
30 return "pending";
31}
32
33/**
34 * Whether a failed tool call is the person stopping it (a denied permission,
35 * Esc) rather than the work failing. Those never count toward red.
36 */
37export function isStoppedByUser(text: string | undefined): boolean {
38 if (text === undefined) {
39 return false;
40 }
41 return (
42 text.startsWith("The user doesn't want to proceed") ||
43 text.includes("Request interrupted by user") ||
44 text.includes("The tool use was rejected")
45 );
46}
47
48/** The first line of an error, short enough for a label. */
49export function errorDetail(text: string | undefined): string {
50 const line = (text ?? "tool error").split("\n", 1)[0] ?? "tool error";
51 return line.length > 120 ? `${line.slice(0, 119)}…` : line;
52}
53