SLOPSHOPPER

flowctl

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

newguardcommandtoaststatusprompt
★ 7v1.0.6MITupdated 2026-10-16MarcoFilho1/FlowCTL/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flowctl
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /flowctl ⎿ flowctl: /flowctl init install or update the overlay app (downloads the release of this plugin's versio ⎿ flowctl: /flowctl init --force install it again ⎿ flowctl: /flowctl init --from-source build it from the tagged source instead (macOS only; needs pnpm, Node 22 and Swi ⎿ flowctl: /flowctl uninstall remove the app ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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">

Why

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.

ColorMeaning
whitepending
bluein progress
yellowneeds you: a permission prompt or a question
redfailing: a tool error flashes it, repeated ones keep it red
greendone
faded, dashedskipped: the turn ended without starting it (left out of the progress count)

Features

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.

The plan as a graph

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.

Errors flash red

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">

Yellow means you

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">

Done? You'll know

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">

Every prompt grows the graph

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">

Make it yours

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">

Out of your way when you're not

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">

Install

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
  1. The plugin. The first line adds this repository as a plugin marketplace (answer y), then installs the plugin. Choose the user scope so it works in every project. The plugin watches the session and builds the graph.
  2. The overlay app. /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.

Day to day

You install once. You do not run either command again when you open a new session:

  • Every Claude Code session you start loads the plugin by itself.
  • When a session starts, the plugin opens the overlay if it is not already running. Several sessions share one window, one page each.
  • The window shows itself when a session starts working and fades out after five minutes with nothing running. Close it with × and it stays closed until you reopen it from the menu bar (or tray) icon.

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.

Commands and options

CommandWhat it does
/flowctl initInstalls or updates the overlay app to the plugin's version; opens it if it is already there
/flowctl init --forceInstalls it again
/flowctl init --from-sourceBuilds it from the tagged source instead of downloading (macOS only; needs pnpm, Node 22 and Swift)
/flowctl uninstallQuits 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):

OptionDefault
Open the overlayonOpen the app when a session starts, if it is installed
Update the overlayonUpdate the app at a session start when it is older than the plugin
Ask the agent to declare its planonWithout it, the graph is usually one node
Errors before red3Consecutive tool errors that turn the current step red
Record eventsoffKeep the session's events in ~/.flowctl/recordings, for bug reports

Privacy

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.

How it works

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.

Roadmap

  • Linux. The plugin, the viewer and the Windows app (Tauri) already run there; packaging it is next.
  • Your ideas: open an issue.

Develop

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.

License

MIT

Source 14 files
hooks/register.ts 521 lines
1import 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};
521
graph/reduce.ts 1025 lines
1// 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}
1025
graph/snapshot.ts 37 lines
1// 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}
37
graph/types.ts 115 lines
1// 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}
115
live/present.ts 139 lines
1// 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}
139
live/install.ts 328 lines
1// `/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");
328
live/platform.ts 16 lines
1// 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}
16
live/rebuild.ts 100 lines
1// 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}
100
live/transport.ts 103 lines
1// 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}
103
live/windows.ts 99 lines
1// 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}
99
recorder/recording.ts 125 lines
1// 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}
125
graph/status.ts 53 lines
1// 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