@marcoscale98/pi-extensible-workflows 5.14.1-fork.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/CHANGELOG.md +536 -0
  2. package/README.md +64 -0
  3. package/dist/src/agent-execution.d.ts +338 -0
  4. package/dist/src/agent-execution.js +1633 -0
  5. package/dist/src/background-widget.d.ts +74 -0
  6. package/dist/src/background-widget.js +1340 -0
  7. package/dist/src/budget.d.ts +38 -0
  8. package/dist/src/budget.js +188 -0
  9. package/dist/src/changelog.d.ts +6 -0
  10. package/dist/src/changelog.js +112 -0
  11. package/dist/src/decoders.d.ts +96 -0
  12. package/dist/src/decoders.js +629 -0
  13. package/dist/src/execution.d.ts +19 -0
  14. package/dist/src/execution.js +749 -0
  15. package/dist/src/herdr.d.ts +53 -0
  16. package/dist/src/herdr.js +270 -0
  17. package/dist/src/host-delivery.d.ts +95 -0
  18. package/dist/src/host-delivery.js +376 -0
  19. package/dist/src/host-navigator.d.ts +83 -0
  20. package/dist/src/host-navigator.js +1087 -0
  21. package/dist/src/host-phases.d.ts +89 -0
  22. package/dist/src/host-phases.js +320 -0
  23. package/dist/src/host-recovery.d.ts +141 -0
  24. package/dist/src/host-recovery.js +356 -0
  25. package/dist/src/host-runtime.d.ts +78 -0
  26. package/dist/src/host-runtime.js +378 -0
  27. package/dist/src/host-view.d.ts +116 -0
  28. package/dist/src/host-view.js +868 -0
  29. package/dist/src/host.d.ts +40 -0
  30. package/dist/src/host.js +1641 -0
  31. package/dist/src/index.d.ts +29 -0
  32. package/dist/src/index.js +13725 -0
  33. package/dist/src/index.js.map +6 -0
  34. package/dist/src/io.d.ts +14 -0
  35. package/dist/src/io.js +82 -0
  36. package/dist/src/package-identity.d.ts +4 -0
  37. package/dist/src/package-identity.js +6 -0
  38. package/dist/src/paths.d.ts +14 -0
  39. package/dist/src/paths.js +51 -0
  40. package/dist/src/persistence.d.ts +5 -0
  41. package/dist/src/persistence.js +5 -0
  42. package/dist/src/pi-runtime-adapter.d.ts +27 -0
  43. package/dist/src/pi-runtime-adapter.js +243 -0
  44. package/dist/src/pi-runtime-runner.d.ts +42 -0
  45. package/dist/src/pi-runtime-runner.js +505 -0
  46. package/dist/src/registry.d.ts +48 -0
  47. package/dist/src/registry.js +379 -0
  48. package/dist/src/retention.d.ts +17 -0
  49. package/dist/src/retention.js +195 -0
  50. package/dist/src/runtime/agent-runner.d.ts +143 -0
  51. package/dist/src/runtime/agent-runner.js +12 -0
  52. package/dist/src/runtime/index.d.ts +2 -0
  53. package/dist/src/runtime/index.js +2 -0
  54. package/dist/src/runtime/namespace.d.ts +14 -0
  55. package/dist/src/runtime/namespace.js +1 -0
  56. package/dist/src/runtime/workflow-result.d.ts +2 -0
  57. package/dist/src/runtime/workflow-result.js +6 -0
  58. package/dist/src/session-handoff.d.ts +2 -0
  59. package/dist/src/session-handoff.js +82 -0
  60. package/dist/src/session-lease.d.ts +12 -0
  61. package/dist/src/session-lease.js +180 -0
  62. package/dist/src/store.d.ts +95 -0
  63. package/dist/src/store.js +951 -0
  64. package/dist/src/subagent-manager-handle.d.ts +4 -0
  65. package/dist/src/subagent-manager-handle.js +6 -0
  66. package/dist/src/tool-timing.d.ts +12 -0
  67. package/dist/src/tool-timing.js +33 -0
  68. package/dist/src/trajectory-contracts.d.ts +8 -0
  69. package/dist/src/trajectory-contracts.js +9 -0
  70. package/dist/src/trajectory-host-handle.d.ts +12 -0
  71. package/dist/src/trajectory-host-handle.js +6 -0
  72. package/dist/src/trajectory.d.ts +128 -0
  73. package/dist/src/trajectory.js +594 -0
  74. package/dist/src/types.d.ts +960 -0
  75. package/dist/src/types.js +60 -0
  76. package/dist/src/utils.d.ts +57 -0
  77. package/dist/src/utils.js +225 -0
  78. package/dist/src/validation.d.ts +29 -0
  79. package/dist/src/validation.js +1100 -0
  80. package/dist/src/workflow-artifacts.d.ts +14 -0
  81. package/dist/src/workflow-artifacts.js +40 -0
  82. package/dist/starter/index.d.ts +1 -0
  83. package/dist/starter/index.js +103 -0
  84. package/dist/starter/index.js.map +6 -0
  85. package/dist/starter/review-loop.d.ts +72 -0
  86. package/dist/starter/review-loop.js +43 -0
  87. package/dist/starter/roles/developer.md +6 -0
  88. package/dist/starter/roles/oracle.md +18 -0
  89. package/dist/starter/roles/researcher.md +20 -0
  90. package/dist/starter/roles/reviewer.md +19 -0
  91. package/dist/starter/roles/scout.md +18 -0
  92. package/dist/subagents/index.d.ts +8 -0
  93. package/dist/subagents/index.js +8021 -0
  94. package/dist/subagents/index.js.map +6 -0
  95. package/dist/subagents/src/contracts.d.ts +173 -0
  96. package/dist/subagents/src/contracts.js +68 -0
  97. package/dist/subagents/src/decode.d.ts +15 -0
  98. package/dist/subagents/src/decode.js +182 -0
  99. package/dist/subagents/src/index.d.ts +12 -0
  100. package/dist/subagents/src/index.js +180 -0
  101. package/dist/subagents/src/manager.d.ts +3 -0
  102. package/dist/subagents/src/manager.js +1636 -0
  103. package/dist/subagents/src/navigator.d.ts +5 -0
  104. package/dist/subagents/src/navigator.js +627 -0
  105. package/dist/subagents/src/view.d.ts +70 -0
  106. package/dist/subagents/src/view.js +387 -0
  107. package/dist/subagents/src/worktree.d.ts +34 -0
  108. package/dist/subagents/src/worktree.js +68 -0
  109. package/dist/trajectory/assets/favicon.png +0 -0
  110. package/dist/trajectory/assets/index.html +772 -0
  111. package/dist/trajectory/assets/marked.min.js +6 -0
  112. package/dist/trajectory/assets/morphdom.min.js +1 -0
  113. package/dist/trajectory/index.d.ts +5 -0
  114. package/dist/trajectory/index.js +3459 -0
  115. package/dist/trajectory/index.js.map +6 -0
  116. package/dist/trajectory/src/assets/favicon.png +0 -0
  117. package/dist/trajectory/src/assets/index.html +772 -0
  118. package/dist/trajectory/src/assets/marked.min.js +6 -0
  119. package/dist/trajectory/src/assets/morphdom.min.js +1 -0
  120. package/dist/trajectory/src/export.d.ts +25 -0
  121. package/dist/trajectory/src/export.js +80 -0
  122. package/dist/trajectory/src/index.d.ts +26 -0
  123. package/dist/trajectory/src/index.js +732 -0
  124. package/dist/trajectory/src/server.d.ts +7 -0
  125. package/dist/trajectory/src/server.js +2534 -0
  126. package/dist/trajectory/src/server.js.map +6 -0
  127. package/examples/advanced-patterns/auto-model-router.mjs +78 -0
  128. package/examples/workflow-extension-template/README.md +37 -0
  129. package/examples/workflow-extension-template/extension.test.mjs +59 -0
  130. package/examples/workflow-extension-template/index.js +51 -0
  131. package/examples/workflow-extension-template/roles/reviewer.md +4 -0
  132. package/package.json +105 -0
  133. package/skills/pi-extensible-workflows/SKILL.md +160 -0
  134. package/src/agent-execution.ts +1533 -0
  135. package/src/background-widget.ts +1496 -0
  136. package/src/budget.ts +109 -0
  137. package/src/changelog.ts +114 -0
  138. package/src/decoders.ts +567 -0
  139. package/src/execution.ts +652 -0
  140. package/src/herdr.ts +240 -0
  141. package/src/host-delivery.ts +346 -0
  142. package/src/host-navigator.ts +871 -0
  143. package/src/host-phases.ts +264 -0
  144. package/src/host-recovery.ts +333 -0
  145. package/src/host-runtime.ts +373 -0
  146. package/src/host-view.ts +828 -0
  147. package/src/host.ts +1502 -0
  148. package/src/index.ts +29 -0
  149. package/src/io.ts +73 -0
  150. package/src/package-identity.ts +7 -0
  151. package/src/paths.ts +49 -0
  152. package/src/persistence.ts +11 -0
  153. package/src/pi-runtime-adapter.ts +247 -0
  154. package/src/pi-runtime-runner.ts +436 -0
  155. package/src/registry.ts +321 -0
  156. package/src/retention.ts +178 -0
  157. package/src/runtime/agent-runner.ts +158 -0
  158. package/src/runtime/index.ts +2 -0
  159. package/src/runtime/namespace.ts +16 -0
  160. package/src/runtime/workflow-result.ts +8 -0
  161. package/src/session-handoff.ts +78 -0
  162. package/src/session-lease.ts +125 -0
  163. package/src/store.ts +832 -0
  164. package/src/subagent-manager-handle.ts +8 -0
  165. package/src/tool-timing.ts +47 -0
  166. package/src/trajectory-contracts.ts +13 -0
  167. package/src/trajectory-host-handle.ts +16 -0
  168. package/src/trajectory.ts +543 -0
  169. package/src/types.ts +374 -0
  170. package/src/utils.ts +192 -0
  171. package/src/validation.ts +910 -0
  172. package/src/workflow-artifacts.ts +35 -0
  173. package/starter/index.ts +35 -0
  174. package/starter/prompts/council.md +16 -0
  175. package/starter/prompts/deep-research.md +16 -0
  176. package/starter/prompts/oracle.md +14 -0
  177. package/starter/prompts/parallel-review.md +22 -0
  178. package/starter/prompts/parallel-scout.md +16 -0
  179. package/starter/prompts/review-loop.md +14 -0
  180. package/starter/prompts/review.md +14 -0
  181. package/starter/prompts/scout.md +14 -0
  182. package/starter/review-loop.ts +73 -0
  183. package/starter/roles/developer.md +6 -0
  184. package/starter/roles/oracle.md +18 -0
  185. package/starter/roles/researcher.md +20 -0
  186. package/starter/roles/reviewer.md +19 -0
  187. package/starter/roles/scout.md +18 -0
  188. package/subagents/README.md +145 -0
  189. package/subagents/index.ts +8 -0
  190. package/subagents/src/contracts.ts +176 -0
  191. package/subagents/src/decode.ts +158 -0
  192. package/subagents/src/index.ts +194 -0
  193. package/subagents/src/manager.ts +1560 -0
  194. package/subagents/src/navigator.ts +488 -0
  195. package/subagents/src/view.ts +376 -0
  196. package/subagents/src/worktree.ts +99 -0
  197. package/trajectory/index.ts +5 -0
  198. package/trajectory/src/assets/favicon.png +0 -0
  199. package/trajectory/src/assets/index.html +772 -0
  200. package/trajectory/src/assets/marked.min.js +6 -0
  201. package/trajectory/src/assets/morphdom.min.js +1 -0
  202. package/trajectory/src/export.ts +85 -0
  203. package/trajectory/src/index.ts +604 -0
  204. package/trajectory/src/server.ts +447 -0
@@ -0,0 +1,1496 @@
1
+ /**
2
+ * Live workflow widget: a tree of the runs happening right now, drawn below the
3
+ * editor, and a durable receipt in the transcript once each one reaches a
4
+ * terminal state.
5
+ *
6
+ * The split is deliberate. The widget answers "what is happening" and holds
7
+ * nothing that has reached a final state — resumable interruptions remain visible.
8
+ * The transcript entry answers "what did that cost and who did it", and keeps the
9
+ * answer for the life of the session rather than for a minute.
10
+ *
11
+ * Run state arrives as workflow events; the numbers behind it (tokens, cost,
12
+ * per-agent accounting) live only in the run's state.json, so an event is a
13
+ * signal to re-read rather than the data itself. A slower rescan timer checks for
14
+ * changed state files without reparsing unchanged runs.
15
+ */
16
+
17
+ import { readFileSync, statSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
20
+ import { isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
21
+ import type { AgentRecord, AgentState, RunRecord, RunState } from "./types.js";
22
+ import { listRunIds, RunStore } from "./persistence.js";
23
+ import {
24
+ BUDGET_DIMENSIONS,
25
+ HARD_TERMINAL_RUN_STATES,
26
+ SETTLED_AGENT_STATES,
27
+ WORKFLOW_AGENT_STALL_THRESHOLD_MS,
28
+ WORKFLOW_AGENT_STATE_CHANGED_EVENT,
29
+ WORKFLOW_BUDGET_EVENT,
30
+ WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT,
31
+ WORKFLOW_PHASE_CHANGED_EVENT,
32
+ WORKFLOW_RUN_COMPLETED_EVENT,
33
+ WORKFLOW_RUN_FAILED_EVENT,
34
+ WORKFLOW_RUN_STARTED_EVENT,
35
+ WORKFLOW_RUN_STATE_CHANGED_EVENT,
36
+ } from "./types.js";
37
+ import { finiteNumber, object } from "./utils.js";
38
+
39
+ export type BackgroundWidgetAPI = Pick<ExtensionAPI, "appendEntry" | "on"> & {
40
+ events?: { on?: (name: string, handler: (event: unknown) => void) => () => void; emit?: ExtensionAPI["events"]["emit"] };
41
+ registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"];
42
+ registerShortcut?: ExtensionAPI["registerShortcut"];
43
+ };
44
+
45
+ const KEY = "piewf-widget";
46
+ const ENTRY_TYPE = "piewf-run-receipt";
47
+
48
+ /**
49
+ * Repaint interval. Fast enough that the spinner reads as motion rather than a
50
+ * stutter; the underlying data is only re-read when an event says it changed.
51
+ */
52
+ const REPAINT_MS = 125;
53
+
54
+ /**
55
+ * Filesystem rescan interval. Shell-only activity has no event, but it does not
56
+ * need to be polled at the animation cadence.
57
+ */
58
+ const RESCAN_MS = 1000;
59
+
60
+ /**
61
+ * Rows the widget will occupy, border included.
62
+ *
63
+ * Ten is a deliberate ceiling rather than a technical one: the widget sits
64
+ * under the editor, and anything taller pushes the conversation off the top of
65
+ * a normal terminal to report on work that is, by definition, happening
66
+ * elsewhere. Deeper detail is a keypress away rather than always on screen.
67
+ */
68
+ const MAX_ROWS = 10;
69
+
70
+ /**
71
+ * How long an agent may go without an event before the widget says so.
72
+ *
73
+ * Deliberately shorter than the core's own stall threshold. That one answers
74
+ * "is this agent dead"; this one answers "should you go and look". An agent
75
+ * asking a question in its own pane produces exactly this signature — no tool
76
+ * calls, no events, waiting on a person — and the core cannot see it, since the
77
+ * pane is a separate process. Three minutes is longer than any single stretch
78
+ * of thinking and far shorter than the ten minutes it takes to be declared
79
+ * stalled.
80
+ */
81
+ const QUIET_MS = 3 * 60 * 1000;
82
+
83
+ /** The core's own threshold for calling an agent stalled outright. */
84
+ const STALL_MS = WORKFLOW_AGENT_STALL_THRESHOLD_MS;
85
+
86
+ /**
87
+ * How long a phase's shell commands must run before they are worth a row. A
88
+ * phase built entirely of short commands would otherwise flicker a line in and
89
+ * out; one that sits on a build or a test suite is exactly what you want to see.
90
+ */
91
+ const SHELL_VISIBLE_MS = 5000;
92
+
93
+ /**
94
+ * The shortcut that focuses the widget.
95
+ *
96
+ * `↓` from an empty editor is the comfortable way in, but an empty editor is
97
+ * also what every picker and dialog leaves behind while it waits for arrows, so
98
+ * that gesture has to defer to whatever else is on screen. This one never has
99
+ * to — and being registered through Pi, it can be rebound in `keybindings.json`
100
+ * like any other shortcut.
101
+ */
102
+ const FOCUS_SHORTCUT = "alt+o";
103
+
104
+ /**
105
+ * Whether the widget accepts the keyboard.
106
+ *
107
+ * Scrolling earns its keys: ten rows cannot hold five runs three phases deep,
108
+ * and what gets folded away at rest is the widget's choice rather than the
109
+ * reader's. The shortcut is the only way in — `↓` on an empty editor belongs
110
+ * to whichever picker opens next.
111
+ */
112
+ const NAVIGATION_ENABLED = true;
113
+
114
+ /**
115
+ * Overrides the switch above, for the tests that cover navigation.
116
+ *
117
+ * The behaviour is finished and tested; only its exposure is withheld. Letting
118
+ * the tests turn it on keeps that coverage alive rather than letting it rot
119
+ * while the feature waits.
120
+ */
121
+ export const __navigationForTests = { enabled: NAVIGATION_ENABLED };
122
+
123
+ /**
124
+ * Colours come from the active theme, never from here.
125
+ *
126
+ * A widget that spelled out its own 24-bit escapes would look right on the
127
+ * author's terminal and wrong on a 256-colour or light theme, and would ignore
128
+ * whatever the reader chose. `Theme.fg` resolves a named role against the
129
+ * theme in force, so the widget follows it.
130
+ *
131
+ * These names are the roles used, kept in one place so a colour decision is
132
+ * visible as a decision rather than scattered through the drawing code.
133
+ */
134
+ const ROLE = {
135
+ live: "accent",
136
+ done: "success",
137
+ failed: "error",
138
+ warn: "warning",
139
+ quiet: "muted",
140
+ } as const satisfies Record<string, Parameters<Theme["fg"]>[0]>;
141
+ const RESET = "\x1b[0m";
142
+
143
+ /**
144
+ * Paints text in a themed colour role. Falls back to the bare text when no
145
+ * theme is available, so a frame rendered outside the TUI is still legible.
146
+ */
147
+ type Paint = (role: Parameters<Theme["fg"]>[0], text: string) => string;
148
+
149
+ /**
150
+ * States that mean work is still happening, derived from the core's own lists
151
+ * rather than restated here: a state the widget does not recognise reads as
152
+ * finished, and the whole frame vanishes mid-run. Naming what is *over* rather
153
+ * than what is live means a state added upstream is treated as live by default
154
+ * — the safe direction, since drawing a finished run costs a stale row while
155
+ * missing a live one loses the display entirely.
156
+ */
157
+ const isRunTerminal = (state: string | undefined): boolean =>
158
+ state !== undefined && HARD_TERMINAL_RUN_STATES.has(state as RunState);
159
+ const isRunLive = (state: string | undefined): boolean =>
160
+ state !== undefined && !isRunTerminal(state);
161
+ const isAgentLive = (state: string | undefined): boolean =>
162
+ state !== undefined && !SETTLED_AGENT_STATES.has(state as AgentState);
163
+
164
+ /**
165
+ * Braille wheel, one glyph per repaint.
166
+ *
167
+ * No repeated glyph at the ends: with the first and last the same, the wheel
168
+ * stalls for a frame on every turn and reads as a stutter.
169
+ */
170
+ const SPINNER = ["⣷", "⣯", "⣟", "⡿", "⢿", "⣽", "⣻"] as const;
171
+
172
+ /**
173
+ * A run as the widget sees it: the core's own record, plus where it was read
174
+ * from and when it started.
175
+ *
176
+ * The shape is imported rather than restated. Restating it means the widget's
177
+ * idea of a run silently drifts from the runtime's — which is exactly how a
178
+ * live state ends up unrecognised and the whole frame disappears.
179
+ */
180
+ interface Run extends Partial<RunRecord> {
181
+ directory: string;
182
+ startedAt: number;
183
+ /** A checkpoint waiting for an answer, learned from events rather than disk. */
184
+ waiting?: { name: string; since: number };
185
+ }
186
+
187
+ interface ReceiptAgent {
188
+ id?: string;
189
+ parentId?: string;
190
+ name: string;
191
+ state: string;
192
+ model?: string;
193
+ role?: string;
194
+ requestedModel?: string;
195
+ tools?: readonly string[];
196
+ /** Accepted for rendering older receipt entries; new receipts persist granted tools. */
197
+ toolCalls?: number;
198
+ input: number;
199
+ output: number;
200
+ cacheRead: number;
201
+ costUsd: number;
202
+ durationMs: number;
203
+ attempts: number;
204
+ }
205
+
206
+ interface Receipt {
207
+ runId: string;
208
+ workflow: string;
209
+ state: string;
210
+ costUsd: number;
211
+ tokens: number;
212
+ durationMs: number;
213
+ phases: readonly string[];
214
+ phaseBoundaries: readonly number[];
215
+ agents: readonly ReceiptAgent[];
216
+ error?: string;
217
+ }
218
+
219
+ function spinner(now: number): string {
220
+ // Tied to the repaint interval rather than a period of its own: a wheel that
221
+ // advances faster than the screen redraws skips glyphs and reads as jitter.
222
+ return SPINNER[Math.floor(now / REPAINT_MS) % SPINNER.length] ?? SPINNER[0];
223
+ }
224
+
225
+ /** Status mark for a finished-or-running unit of work. */
226
+ function mark(state: string | undefined, now: number, paint: Paint, runState?: string): string {
227
+ if (state === "running" && (runState === undefined || runState === "running")) return paint(ROLE.live, spinner(now));
228
+ if (state === "completed") return paint(ROLE.done, "✓");
229
+ if (state === "failed" || state === "budget_exhausted") return paint(ROLE.failed, "✗");
230
+ return paint(ROLE.quiet, "·");
231
+ }
232
+
233
+ function formatElapsed(ms: number): string {
234
+ const total = Math.max(0, Math.floor(ms / 1000));
235
+ const minutes = Math.floor(total / 60);
236
+ const seconds = total % 60;
237
+ return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
238
+ }
239
+
240
+ export function formatTokens(tokens: number | undefined): string {
241
+ if (!tokens) return "";
242
+ if (tokens < 1000) return `${String(tokens)}t`;
243
+ const thousands = tokens / 1000;
244
+ return `${thousands < 10 ? thousands.toFixed(1) : String(Math.round(thousands))}kt`;
245
+ }
246
+
247
+ export function formatCost(cost: number | undefined): string {
248
+ if (!cost) return "";
249
+ // Three decimals below a cent: at two, every cheap agent reads as $0.00 and
250
+ // the per-agent column becomes a row of zeroes.
251
+ return cost < 0.01 ? `$${cost.toFixed(3)}` : `$${cost.toFixed(2)}`;
252
+ }
253
+
254
+ /**
255
+ * Visible width, measured the way the terminal measures it.
256
+ *
257
+ * Not the string's length: a CJK ideograph is one JavaScript character and two
258
+ * terminal cells, an emoji is often two characters and two cells, and a
259
+ * combining accent is a character that takes none. Pi-tui rejects a line wider
260
+ * than the width it handed out — it does not wrap, it throws — so the widget
261
+ * has to agree with it exactly, and the only way to agree exactly is to use
262
+ * its own measure.
263
+ */
264
+ function visibleLength(text: string): number {
265
+ return visibleWidth(text);
266
+ }
267
+
268
+ /**
269
+ * Cuts a coloured line down to a visible width.
270
+ *
271
+ * Pi-tui's own truncation, for the same reason: it counts cells, keeps escape
272
+ * sequences whole rather than slicing through one and bleeding colour into the
273
+ * border, and never cuts a character in half.
274
+ */
275
+ function truncate(text: string, limit: number): string {
276
+ return `${truncateToWidth(text, limit, "…")}${RESET}`;
277
+ }
278
+
279
+ function optionalNumber(value: unknown): boolean {
280
+ return value === undefined || finiteNumber(value);
281
+ }
282
+
283
+ function renderableAgent(value: unknown): boolean {
284
+ if (!object(value) || typeof value.name !== "string" || typeof value.state !== "string" || !object(value.model) || typeof value.model.model !== "string" || !Array.isArray(value.tools) || !value.tools.every((tool) => typeof tool === "string")) return false;
285
+ if (value.model.thinking !== undefined && typeof value.model.thinking !== "string") return false;
286
+ for (const key of ["id", "label", "parentId", "role", "requestedModel"] as const) if (value[key] !== undefined && typeof value[key] !== "string") return false;
287
+ for (const key of ["attempts", "startedAt", "durationMs", "lastEventAt"] as const) if (!optionalNumber(value[key])) return false;
288
+ const accounting = value.accounting;
289
+ if (accounting !== undefined && (!object(accounting) || !["input", "output", "cacheRead", "cacheWrite", "cost"].every((key) => optionalNumber(accounting[key])))) return false;
290
+ const attemptDetails = value.attemptDetails;
291
+ if (attemptDetails !== undefined && (!Array.isArray(attemptDetails) || attemptDetails.some((detail) => !object(detail)))) return false;
292
+ return true;
293
+ }
294
+
295
+ function renderableRun(value: unknown): value is Partial<RunRecord> {
296
+ if (!object(value) || typeof value.state !== "string") return false;
297
+ if (value.workflowName !== undefined && typeof value.workflowName !== "string") return false;
298
+ if (value.id !== undefined && typeof value.id !== "string") return false;
299
+ if (value.sessionId !== undefined && typeof value.sessionId !== "string") return false;
300
+ if (value.agents !== undefined && (!Array.isArray(value.agents) || value.agents.some((agent) => !renderableAgent(agent)))) return false;
301
+ const history = value.phaseHistory;
302
+ if (history !== undefined && (!Array.isArray(history) || history.some((entry) => !object(entry) || typeof entry.phase !== "string" || !finiteNumber(entry.afterAgent)))) return false;
303
+ const usage = value.usage;
304
+ if (usage !== undefined && (!object(usage) || !BUDGET_DIMENSIONS.every((key) => optionalNumber(usage[key])))) return false;
305
+ const delivery = value.delivery;
306
+ if (delivery !== undefined && (!object(delivery) || typeof delivery.mode !== "string" || typeof delivery.state !== "string")) return false;
307
+ const budgetEvents = value.budgetEvents;
308
+ if (budgetEvents !== undefined && (!Array.isArray(budgetEvents) || budgetEvents.some((event) => !object(event) || typeof event.type !== "string" || !Array.isArray(event.dimensions) || !event.dimensions.every((dimension) => typeof dimension === "string")))) return false;
309
+ const shellActivity = value.activeShellsByPhase;
310
+ if (shellActivity !== undefined && (!Array.isArray(shellActivity) || shellActivity.some((entry) => !object(entry) || !finiteNumber(entry.phaseIndex) || !finiteNumber(entry.active) || !optionalNumber(entry.startedAt)))) return false;
311
+ return true;
312
+ }
313
+
314
+ /**
315
+ * Reads one run's state.json.
316
+ *
317
+ * The run carries no start time of its own and state.json is rewritten whole on
318
+ * every step, so its birth time keeps moving. The directory is created once and
319
+ * never touched again — that is the start. Taking it from the file made the
320
+ * clock restart on every phase.
321
+ */
322
+ function readRun(directory: string): Run | undefined {
323
+ try {
324
+ const state: unknown = JSON.parse(readFileSync(join(directory, "state.json"), "utf8"));
325
+ if (!renderableRun(state)) return undefined;
326
+ const startedAt = statSync(directory).birthtimeMs;
327
+ return { ...state, directory, startedAt };
328
+ } catch {
329
+ // A run mid-write, or a directory without state yet.
330
+ return undefined;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * When this run's agent sessions last grew, by the transcript files themselves.
336
+ *
337
+ * The run state's own `lastEventAt` is not a sign of life: the core keeps it in
338
+ * memory and only writes it out when it persists, so a perfectly busy agent
339
+ * looks silent for minutes at a time. The agent's transcript, on the other
340
+ * hand, is appended to on every turn — if that file is growing, work is
341
+ * happening, whatever the run state says.
342
+ */
343
+ /**
344
+ * When an agent's own transcript was last written.
345
+ *
346
+ * The agent record carries the session for each attempt, so the file belongs
347
+ * to this agent alone. Taking the newest write across every session in the run
348
+ * — as a whole-run reading does — lets one busy agent vouch for a silent one:
349
+ * the pair look equally alive while only one of them is.
350
+ *
351
+ * Stat results are cached for the life of a frame. Without that the check is
352
+ * one stat per agent per repaint, several times a second, for a number that
353
+ * cannot have changed between two rows of the same frame.
354
+ */
355
+ function lastTranscriptWrite(agent: AgentRecord, cache: Map<string, number | undefined>): number | undefined {
356
+ const details = agent.attemptDetails ?? [];
357
+ let newest: number | undefined;
358
+ // Only this agent's attempts: a retry writes a new transcript, and the
359
+ // latest one is what says whether it is still moving.
360
+ for (const detail of details) {
361
+ const locator = detail.session?.locator as { sessionFile?: string } | undefined;
362
+ const file = locator?.sessionFile;
363
+ if (!file) continue;
364
+ if (!cache.has(file)) {
365
+ try {
366
+ cache.set(file, statSync(file).mtimeMs);
367
+ } catch {
368
+ // The transcript moved or was cleaned up; another attempt may answer.
369
+ cache.set(file, undefined);
370
+ }
371
+ }
372
+ const mtime = cache.get(file);
373
+ if (mtime !== undefined && (newest === undefined || mtime > newest)) newest = mtime;
374
+ }
375
+ return newest;
376
+ }
377
+
378
+ /**
379
+ * How long an agent has been silent, once that crosses the quiet threshold.
380
+ *
381
+ * An agent thinking hard looks exactly like an agent whose transport died: both
382
+ * are a spinner that does not stop. Silence separates them — but it has to be
383
+ * measured against the transcript, not the run state.
384
+ */
385
+ function agentSilentFor(agent: AgentRecord, now: number, cache: Map<string, number | undefined>): number | undefined {
386
+ if (agent.state !== "running") return undefined;
387
+ const last = Math.max(agent.lastEventAt ?? 0, lastTranscriptWrite(agent, cache) ?? 0);
388
+ if (last === 0) return undefined;
389
+ const silent = now - last;
390
+ return silent >= QUIET_MS ? silent : undefined;
391
+ }
392
+
393
+ /**
394
+ * Shell activity for one phase, when it has been running long enough to matter.
395
+ *
396
+ * A phase of pure shell work has no agents, so without this it draws as a bare
397
+ * name with nothing under it and reads as stalled.
398
+ */
399
+ function phaseShellRow(run: Run, phaseIndex: number, now: number): { label: string; elapsed: string } | undefined {
400
+ const entry = run.activeShellsByPhase?.find((item) => item.phaseIndex === phaseIndex);
401
+ const active = entry?.active ?? 0;
402
+ if (active <= 0) return undefined;
403
+ const startedAt = entry?.startedAt;
404
+ if (startedAt === undefined || now - startedAt < SHELL_VISIBLE_MS) return undefined;
405
+ const label = active === 1 ? "1 command" : `${String(active)} commands`;
406
+ // The elapsed time is returned apart from the label so it can join the other
407
+ // figures against the right-hand rule instead of trailing the text.
408
+ return { label, elapsed: formatElapsed(now - startedAt) };
409
+ }
410
+
411
+ /** The worst budget threshold a run has crossed, and on which dimensions. */
412
+ function budgetWarning(run: Run): string | undefined {
413
+ const events = run.budgetEvents ?? [];
414
+ if (events.length === 0) return undefined;
415
+ const worst = ["hard_exhausted", "hard_overrun", "soft_crossed"].find((type) =>
416
+ events.some((event) => event.type === type),
417
+ );
418
+ if (!worst) return undefined;
419
+ const dimensions = [
420
+ ...new Set(
421
+ events.filter((event) => event.type === worst).flatMap((event) => event.dimensions),
422
+ ),
423
+ ];
424
+ const what = dimensions.length > 0 ? ` (${dimensions.join(", ")})` : "";
425
+ if (worst === "soft_crossed") return `near budget${what}`;
426
+ return worst === "hard_overrun" ? `over budget${what}` : `budget exhausted${what}`;
427
+ }
428
+
429
+ function agentTokens(agent: AgentRecord): number {
430
+ const accounting = agent.accounting;
431
+ if (!accounting) return 0;
432
+ return accounting.input + accounting.output;
433
+ }
434
+
435
+ /** Phase names in the order they opened, with the agents that ran in each. */
436
+ type PhaseSlice = { phase: string; agents: readonly AgentRecord[]; phaseIndex?: number };
437
+
438
+ function phaseSlices(run: Run): PhaseSlice[] {
439
+ const history = run.phaseHistory ?? [];
440
+ const agents = run.agents ?? [];
441
+ if (history.length === 0) return agents.length === 0 ? [] : [{ phase: "unphased", agents }];
442
+
443
+ const bound = (value: number): number => Math.max(0, Math.min(value, agents.length));
444
+ const slices: PhaseSlice[] = [];
445
+ const first = bound(history[0]?.afterAgent ?? 0);
446
+ if (first > 0) slices.push({ phase: "unphased", agents: agents.slice(0, first) });
447
+ history.forEach((entry, index) => {
448
+ const from = bound(entry.afterAgent);
449
+ const next = history[index + 1];
450
+ const to = next === undefined ? agents.length : bound(next.afterAgent);
451
+ slices.push({ phase: entry.phase, phaseIndex: index, agents: agents.slice(from, to) });
452
+ });
453
+ return slices;
454
+ }
455
+
456
+ /**
457
+ * The slice of Pi's TUI this widget needs: who holds the keyboard.
458
+ *
459
+ * Declared structurally rather than imported, so an older host missing either
460
+ * accessor degrades to "nothing else is focused" instead of failing to load.
461
+ */
462
+ interface TuiHandle {
463
+ hasOverlay?: () => boolean;
464
+ getFocusedComponent?: () => unknown;
465
+ requestRender?: () => void;
466
+ }
467
+
468
+ interface Row {
469
+ text: string;
470
+ /**
471
+ * Numbers to set against the right-hand rule rather than trailing the name.
472
+ *
473
+ * Names are ragged — a phase called `plan` beside one called `refactor-2` —
474
+ * so numbers that follow them are ragged too, and comparing the cost of one
475
+ * agent against another means reading across a gap that moves on every row.
476
+ * Held to the right edge, they form a column.
477
+ */
478
+ right?: string;
479
+ /** 2 = a run header or something needing an answer, 1 = live or failed work, 0 = finished detail. */
480
+ rank: number;
481
+ /**
482
+ * Set on the one row that stands for a whole run.
483
+ *
484
+ * Rank alone cannot protect it: a checkpoint needs an answer and so shares
485
+ * the top rank, and enough of them would spend the budget before a later
486
+ * run's header is reached. A run missing its header is not a terse run, it
487
+ * is an invisible one.
488
+ */
489
+ header?: boolean;
490
+ /**
491
+ * A stable name for a row that can be folded shut, and the key of the row it
492
+ * hangs from. Together they let the cursor walk a tree rather than a list:
493
+ * collapsing a phase hides its agents, collapsing a run hides everything
494
+ * under it, and the choice survives the repaint that follows.
495
+ */
496
+ key?: string;
497
+ parent?: string;
498
+ }
499
+
500
+ /**
501
+ * Folds the least interesting rows away once the tree outgrows its budget,
502
+ * leaving a marker in their place. Run headers are prioritized so every visible
503
+ * run retains its title without exceeding the frame budget.
504
+ */
505
+ /**
506
+ * The furthest the window may travel: the offset whose window ends on the last
507
+ * row of the tree.
508
+ *
509
+ * One number, used by the window, by the scrollbar and by the key handler
510
+ * alike. Three separate clamps is how the thumb reaches the bottom while the
511
+ * content still has rows to give, and how presses past the end pile up an
512
+ * offset nothing can see — paid back later as arrow presses that do nothing.
513
+ */
514
+ function maxOffset(total: number, budget: number): number {
515
+ if (total <= budget) return 0;
516
+ return Math.max(0, total - (budget - 1));
517
+ }
518
+
519
+ function collapse(rows: readonly Row[], budget: number, paint: Paint, offset: number, scrolling: boolean): Row[] {
520
+ if (rows.length <= budget) return [...rows];
521
+
522
+ // Scrolling: a window onto the tree, with the count of what lies past the
523
+ // bottom edge. Reading it is a matter of moving the window rather than
524
+ // deciding for the reader which rows deserve the space.
525
+ if (scrolling) {
526
+ const start = Math.min(offset, maxOffset(rows.length, budget));
527
+ const window = rows.slice(start, start + budget - 1);
528
+ const below = rows.length - start - window.length;
529
+ return [
530
+ ...window,
531
+ { rank: 0, text: paint(ROLE.quiet, below > 0 ? `… ${String(below)} more` : `… ${String(start)} above`) },
532
+ ];
533
+ }
534
+
535
+ // At rest: keep the rows that say most, since nobody is steering. Reserve
536
+ // one body row for the marker unless headers alone fill the budget.
537
+ const keep = new Set<number>();
538
+ const headerCount = rows.filter((row) => row.rank === 2 && row.header === true).length;
539
+ const reserveMarker = headerCount !== budget;
540
+ const capacity = reserveMarker ? Math.max(0, budget - 1) : budget;
541
+ for (const [index, row] of rows.entries()) {
542
+ if (row.rank === 2 && row.header === true && keep.size < capacity) keep.add(index);
543
+ }
544
+ for (const rank of [2, 1, 0]) {
545
+ for (const [index, row] of rows.entries()) {
546
+ if (row.rank === rank && keep.size < capacity) keep.add(index);
547
+ }
548
+ }
549
+
550
+ const out: Row[] = [];
551
+ let hidden = 0;
552
+ for (const [index, row] of rows.entries()) {
553
+ if (keep.has(index)) out.push(row);
554
+ else hidden += 1;
555
+ }
556
+ if (hidden > 0 && reserveMarker) out.push({ rank: 0, text: paint(ROLE.quiet, `… ${String(hidden)} more`) });
557
+ return out.slice(0, budget);
558
+ }
559
+
560
+
561
+
562
+ /** The whole widget frame, or undefined when nothing is running. */
563
+ function renderFrame(runs: readonly Run[], now: number, width: number, offset = 0, theme?: Theme, scrolling = false, sizeOut: { rows: number } = { rows: 0 }): string[] | undefined {
564
+ // Only live background runs.
565
+ //
566
+ // A finished run leaves its own receipt in the transcript, so holding it
567
+ // above the editor afterwards says the same thing twice — and the transcript
568
+ // keeps it for good, where the widget only kept it for a minute.
569
+ //
570
+ // A foreground run is already drawn by the workflow tool's own output, in
571
+ // more detail than this can offer: it has the live activity and token counts
572
+ // that never reach the run state on disk. Drawing it here too would be two
573
+ // views of one run disagreeing with each other. Background runs have no such
574
+ // view — the tool call returned the moment they started.
575
+ const live = runs.filter((run) => isRunLive(run.state) && run.delivery?.mode !== "foreground");
576
+ if (live.length === 0) return undefined;
577
+
578
+ const paint: Paint = (role, text) => (theme ? theme.fg(role, text) : text);
579
+ // One stat per transcript per frame, not per agent per repaint.
580
+ const mtimes = new Map<string, number | undefined>();
581
+
582
+ const rows: Row[] = [];
583
+ for (const run of live) {
584
+ const stats = [formatTokens(run.usage?.tokens), formatCost(run.usage?.costUsd)]
585
+ .filter(Boolean)
586
+ .join(" · ");
587
+ const budget = budgetWarning(run);
588
+ const state = run.state ?? "unknown";
589
+ const runKey = `run:${run.id ?? ""}`;
590
+ rows.push({
591
+ rank: 2,
592
+ header: true,
593
+ key: runKey,
594
+ text: `${mark(state, now, paint, state)} ${paint(ROLE.quiet, `[${state}]`)} ${run.workflowName ?? "workflow"}${
595
+ budget ? ` ${paint(ROLE.warn, budget)}` : ""
596
+ }`,
597
+ right: `${stats ? `${stats} · ` : ""}${formatElapsed(now - run.startedAt)}`,
598
+ });
599
+
600
+ // A checkpoint is the one thing here that a person has to act on, and it
601
+ // only exists as an event: the run state on disk never mentions it.
602
+ if (run.waiting) {
603
+ rows.push({
604
+ rank: 2,
605
+ parent: runKey,
606
+ text: ` ${paint(ROLE.warn, "◆")} waiting: ${run.waiting.name}`,
607
+ right: formatElapsed(now - run.waiting.since),
608
+ });
609
+ }
610
+
611
+ const slices = phaseSlices(run);
612
+ slices.forEach((slice, index) => {
613
+ const last = index === slices.length - 1;
614
+ const liveAgents = slice.agents.some(
615
+ (agent) => isAgentLive(agent.state),
616
+ );
617
+ const failed = slice.agents.some((agent) => agent.state === "failed");
618
+ const phaseState = failed ? "failed" : liveAgents && run.state === "running" ? "running" : run.state === "running" ? "completed" : run.state;
619
+
620
+ let cost = 0;
621
+ let tokens = 0;
622
+ for (const agent of slice.agents) {
623
+ cost += agent.accounting?.cost ?? 0;
624
+ tokens += agentTokens(agent);
625
+ }
626
+ const phaseStats = [formatTokens(tokens), formatCost(cost)].filter(Boolean).join(" · ");
627
+
628
+ const phaseKey = `${runKey}/phase:${String(index)}`;
629
+ rows.push({
630
+ rank: liveAgents || failed ? 1 : 0,
631
+ key: phaseKey,
632
+ parent: runKey,
633
+ text: ` ${last ? "╰─" : "├─"} ${mark(phaseState, now, paint, run.state)} ${slice.phase}`,
634
+ ...(phaseStats ? { right: phaseStats } : {}),
635
+ });
636
+
637
+ const stem = last ? " " : " │";
638
+
639
+ // A phase running only shell commands has no agents under it, so without
640
+ // this it reads as a name with nothing happening beneath it.
641
+ const shell = slice.phaseIndex === undefined ? undefined : phaseShellRow(run, slice.phaseIndex, now);
642
+ if (shell) {
643
+ rows.push({
644
+ rank: 1,
645
+ parent: phaseKey,
646
+ text: `${stem} ${slice.agents.length === 0 ? "╰─" : "├─"} ${mark("running", now, paint, run.state)} ${shell.label}`,
647
+ right: shell.elapsed,
648
+ });
649
+ }
650
+
651
+ // Agents nest: one may spawn another, and drawing them as siblings hides
652
+ // who asked for what — a child's cost then reads as a peer's rather than
653
+ // part of the parent's total.
654
+ const children = new Map<string, AgentRecord[]>();
655
+ const roots: AgentRecord[] = [];
656
+ const inSlice = new Set(slice.agents.map((agent) => agent.id));
657
+ for (const agent of slice.agents) {
658
+ const parent = agent.parentId;
659
+ if (parent !== undefined && inSlice.has(parent)) {
660
+ children.set(parent, [...(children.get(parent) ?? []), agent]);
661
+ } else {
662
+ roots.push(agent);
663
+ }
664
+ }
665
+
666
+ const drawAgent = (agent: AgentRecord, prefix: string, lastAgent: boolean, parentKey: string): void => {
667
+ const agentLive = isAgentLive(agent.state);
668
+ const attempts = agent.attempts;
669
+ const detail = [
670
+ `${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`,
671
+ formatTokens(agentTokens(agent)),
672
+ formatCost(agent.accounting?.cost),
673
+ formatElapsed(
674
+ agentLive && agent.startedAt ? now - agent.startedAt : (agent.durationMs ?? 0),
675
+ ),
676
+ // A retry doubles what the agent costs, and only the latest attempt
677
+ // shows in the numbers beside it.
678
+ attempts > 1 ? `attempt ${String(attempts)}` : "",
679
+ ]
680
+ .filter(Boolean)
681
+ .join(" · ");
682
+
683
+ const silent = agentSilentFor(agent, now, mtimes);
684
+ const kids = children.get(agent.id) ?? [];
685
+ const agentKey = `${parentKey}/agent:${agent.id}`;
686
+
687
+ rows.push({
688
+ rank: agentLive || agent.state === "failed" ? 1 : 0,
689
+ parent: parentKey,
690
+ ...(kids.length > 0 ? { key: agentKey } : {}),
691
+ text: `${prefix}${lastAgent ? "╰─" : "├─"} ${
692
+
693
+ silent === undefined
694
+ ? mark(agent.state, now, paint, run.state)
695
+ : paint(silent >= STALL_MS ? ROLE.failed : ROLE.warn, "⚠")
696
+ } ${
697
+ // The label is what the workflow called this agent — `reviewer #2`,
698
+ // `scout (api)` — where the name is the role it was built from. Two
699
+ // agents of one role are otherwise indistinguishable.
700
+ agent.label ?? agent.name
701
+ }${
702
+ silent === undefined
703
+ ? ""
704
+ : ` ${paint(
705
+ silent >= STALL_MS ? ROLE.failed : ROLE.warn,
706
+ `${silent >= STALL_MS ? "stalled" : "quiet"} ${formatElapsed(silent)}`,
707
+ )}`
708
+ }`,
709
+ right: detail,
710
+ });
711
+
712
+ kids.forEach((child, childIndex) => {
713
+ drawAgent(child, `${prefix}${lastAgent ? " " : " │"}`, childIndex === kids.length - 1, agentKey);
714
+ });
715
+ };
716
+
717
+ roots.forEach((agent, agentIndex) => {
718
+ drawAgent(agent, `${stem} `, agentIndex === roots.length - 1, phaseKey);
719
+ });
720
+ });
721
+ }
722
+
723
+ // The rules change colour while scrolling, so it is obvious at a glance that
724
+ // the arrows are going here and not to the editor.
725
+ const accent = (text: string): string => paint(scrolling ? ROLE.warn : ROLE.live, text);
726
+ const title = live.length === 1 ? "Workflow" : `Workflows · ${String(live.length)} runs`;
727
+ sizeOut.rows = rows.length;
728
+ const body = collapse(rows, MAX_ROWS - 2, paint, offset, scrolling);
729
+
730
+ // Width budget: the rule takes a column and a space on each side, so the
731
+ // text has four fewer than the widget is given. Every piece is measured
732
+ // against `inner` so the right-hand rule lands in one column on every row.
733
+ const inner = Math.max(20, width - 4);
734
+
735
+
736
+ // Side rules below the lid, and no floor: the box opens downward into the
737
+ // editor rather than closing on itself, which is one more line kept for the
738
+ // run.
739
+ // A scrollbar on the right rule, drawn only when there is more tree than
740
+ // frame. Without it the window's position is a guess: three rows of a
741
+ // twenty-row tree look the same near the top as near the bottom.
742
+ const total = rows.length;
743
+ const shown = body.length;
744
+ const scrollbar = (index: number): string | undefined => {
745
+ if (!scrolling || total <= shown) return undefined;
746
+ // The thumb is positioned against how far the window can travel, not
747
+ // against the height of the tree. Dividing by the tree instead puts the
748
+ // thumb at the bottom while the window still has a row to give, which is
749
+ // the one thing a scrollbar must never say.
750
+ // The window shows `shown - 1` rows of the tree: the last line of the
751
+ // frame is the marker counting what is out of sight, which belongs to the
752
+ // scrollbar's question rather than to the tree it measures.
753
+ const visible = shown - 1;
754
+ const limit = maxOffset(total, MAX_ROWS - 2);
755
+ const start = Math.min(offset, limit);
756
+ // The thumb spans the visible share of the tree, at least one row, and
757
+ // never the whole track while rows remain out of sight.
758
+ const size = Math.min(shown - 1, Math.max(1, Math.round((visible / total) * shown)));
759
+ // Floor, not round: rounding up on the second-to-last step parks the thumb
760
+ // at the bottom while the window still has a row to travel. The bottom is
761
+ // reserved for the bottom, and reached only by landing on the limit.
762
+ const top = limit === 0 ? 0 : start >= limit ? shown - size : Math.floor((start / limit) * (shown - size));
763
+ return index >= top && index < top + size ? "█" : "░";
764
+ };
765
+
766
+ const lines = body.map((row, index) => {
767
+ // The numbers hold the right edge; the name gives way when the two would
768
+ // meet. A truncated workflow name is still recognisable, where a truncated
769
+ // cost is a different number.
770
+ const right = row.right ?? "";
771
+ const rightWidth = right === "" ? 0 : visibleLength(right) + 2;
772
+ const room = Math.max(0, inner - rightWidth);
773
+ const left = visibleLength(row.text) > room ? truncate(row.text, room) : row.text;
774
+ const gap = Math.max(0, inner - visibleLength(left) - (right === "" ? 0 : visibleLength(right)));
775
+ const text = right === "" ? left + " ".repeat(gap) : `${left}${" ".repeat(gap)}${paint(ROLE.quiet, right)}`;
776
+ const rule = accent("│");
777
+ const bar = scrollbar(index);
778
+ return `${rule} ${text} ${bar === undefined ? rule : accent(bar)}`;
779
+ });
780
+
781
+ // A lid that costs no line of its own.
782
+ //
783
+ // The title row carries the top rule through it — `╭─ Workflow ─── hint ─╮` —
784
+ // rather than sitting under a separate run of dashes. In ten rows a line
785
+ // spent on decoration is a line not spent on the run, and the box still
786
+ // reads as closed at the top.
787
+ // Only advertise navigation when it can reveal hidden rows: a hint for keys
788
+ // that do nothing is worse than no hint.
789
+ const hint = !__navigationForTests.enabled || maxOffset(total, MAX_ROWS - 2) === 0
790
+ ? ""
791
+ : scrolling ? "↑/↓ to scroll · Esc to exit" : "Alt+O to scroll";
792
+ const label = ` ${title} `;
793
+ const tail = hint === "" ? "" : ` ${hint} `;
794
+ const spare = inner + 2 - visibleLength(label) - visibleLength(tail);
795
+ const heading =
796
+ spare >= 2
797
+ ? accent(`╭${label}${"─".repeat(spare)}${tail}╮`)
798
+ : accent(`╭${label}${"─".repeat(Math.max(0, inner + 2 - visibleLength(label)))}╮`);
799
+ // A floor to close the box.
800
+ //
801
+ // Without it the side rules simply stop, and a frame that shrinks — a phase
802
+ // folded away, a run finished — leaves the tail of the taller one behind on
803
+ // screen, since Pi redraws differentially and the rows below are no longer
804
+ // its business. The closing rule gives the eye somewhere to stop and the
805
+ // redraw something to overwrite.
806
+ const foot = accent(`╰${"─".repeat(inner + 2)}╯`);
807
+ return [heading, ...lines, foot];
808
+ }
809
+
810
+ /** Everything worth keeping about a run once it is over. */
811
+ function receiptError(run: Run, fallback: { message?: string } | undefined): string | undefined {
812
+ if (run.error?.message) return run.error.message;
813
+ if (run.state !== "failed") return undefined;
814
+ if (fallback?.message) return fallback.message;
815
+ for (const agent of run.agents ?? []) {
816
+ for (const detail of [...(agent.attemptDetails ?? [])].reverse()) {
817
+ if (detail.error?.message) return detail.error.message;
818
+ }
819
+ }
820
+ return undefined;
821
+ }
822
+ function receiptFor(run: Run, fallback?: { message?: string }): Receipt {
823
+ const slices = phaseSlices(run);
824
+ const agents = slices.flatMap((slice) => slice.agents);
825
+ let boundary = 0;
826
+ const phaseBoundaries = slices.map((slice) => {
827
+ const current = boundary;
828
+ boundary += slice.agents.length;
829
+ return current;
830
+ });
831
+ const error = receiptError(run, fallback);
832
+ return {
833
+ runId: run.id ?? "",
834
+ workflow: run.workflowName ?? "workflow",
835
+ state: run.state ?? "completed",
836
+ costUsd: run.usage?.costUsd ?? 0,
837
+ tokens: run.usage?.tokens ?? 0,
838
+ durationMs: run.usage?.durationMs ?? 0,
839
+ phases: slices.map(({ phase }) => phase),
840
+ phaseBoundaries,
841
+ agents: agents.map((agent) => ({
842
+ id: agent.id,
843
+ ...(agent.parentId === undefined ? {} : { parentId: agent.parentId }),
844
+ // What the workflow called it, falling back to the role it was built
845
+ // from. Two agents of one role are otherwise indistinguishable.
846
+ name: agent.label ?? agent.name,
847
+ state: agent.state,
848
+ // Model and thinking level together: a role picks both, and the model
849
+ // name alone does not say whether it reasoned cheaply or deeply.
850
+ ...(agent.model.model
851
+ ? {
852
+ model: `${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`,
853
+ }
854
+ : {}),
855
+ ...(agent.role ? { role: agent.role } : {}),
856
+ ...(agent.requestedModel ? { requestedModel: agent.requestedModel } : {}),
857
+ tools: [...agent.tools],
858
+ // Cache reads dwarf real input and cost almost nothing, so the split is
859
+ // what explains a cheap run that looks enormous.
860
+ input: agent.accounting?.input ?? 0,
861
+ output: agent.accounting?.output ?? 0,
862
+ cacheRead: agent.accounting?.cacheRead ?? 0,
863
+ costUsd: agent.accounting?.cost ?? 0,
864
+ durationMs: agent.durationMs ?? 0,
865
+ attempts: agent.attempts,
866
+ })),
867
+ ...(error === undefined ? {} : { error }),
868
+ };
869
+ }
870
+
871
+ /** The receipt as transcript lines: the widget's tree, one level deeper. */
872
+ export function renderReceipt(data: Receipt, expanded: boolean, theme: Theme): string[] {
873
+ const glyph = (state: string): string =>
874
+ state === "completed"
875
+ ? theme.fg("success", "✓")
876
+ : state === "failed" || state === "budget_exhausted"
877
+ ? theme.fg("error", "✗")
878
+ : theme.fg("muted", "·");
879
+
880
+ const headline = [
881
+ formatTokens(data.tokens),
882
+ formatCost(data.costUsd) || "$0.00",
883
+ formatElapsed(data.durationMs),
884
+ data.state,
885
+ ]
886
+ .filter(Boolean)
887
+ .join(" · ");
888
+
889
+ const lines = [`${glyph(data.state)} ${theme.bold(data.workflow)} ${theme.fg("muted", headline)}`];
890
+ if (!expanded) return lines;
891
+ data.phases.forEach((phase, index) => {
892
+ const from = data.phaseBoundaries[index] ?? 0;
893
+ const to =
894
+ index + 1 < data.phaseBoundaries.length
895
+ ? (data.phaseBoundaries[index + 1] ?? data.agents.length)
896
+ : data.agents.length;
897
+ const agents = data.agents.slice(from, to);
898
+ const last = index === data.phases.length - 1;
899
+
900
+ let cost = 0;
901
+ let tokens = 0;
902
+ for (const agent of agents) {
903
+ cost += agent.costUsd;
904
+ tokens += agent.input + agent.output;
905
+ }
906
+ const state = agents.some((agent) => agent.state === "failed") ? "failed" : "completed";
907
+ const stats = [formatTokens(tokens), formatCost(cost)].filter(Boolean).join(" · ");
908
+
909
+ lines.push(
910
+ `${last ? "╰─" : "├─"} ${glyph(state)} ${phase}${stats ? theme.fg("muted", ` ${stats}`) : ""}`,
911
+ );
912
+
913
+ const stem = last ? " " : "│ ";
914
+ const inPhase = new Set(agents.flatMap((agent) => agent.id === undefined ? [] : [agent.id]));
915
+ const children = new Map<string, ReceiptAgent[]>();
916
+ const roots: ReceiptAgent[] = [];
917
+ for (const agent of agents) {
918
+ if (agent.parentId !== undefined && inPhase.has(agent.parentId)) {
919
+ children.set(agent.parentId, [...(children.get(agent.parentId) ?? []), agent]);
920
+ } else {
921
+ roots.push(agent);
922
+ }
923
+ }
924
+
925
+ const drawAgent = (agent: ReceiptAgent, prefix: string, lastAgent: boolean): void => {
926
+ const detail = [
927
+ agent.model ?? "",
928
+ formatCost(agent.costUsd),
929
+ formatElapsed(agent.durationMs),
930
+ // A run that only succeeded on the third try cost three times what its
931
+ // final attempt suggests.
932
+ agent.attempts > 1 ? `${String(agent.attempts)} attempts` : "",
933
+ ]
934
+ .filter(Boolean)
935
+ .join(" · ");
936
+
937
+ lines.push(
938
+ `${prefix}${lastAgent ? "╰─" : "├─"} ${glyph(agent.state)} ${agent.name} ${theme.fg("muted", detail)}`,
939
+ );
940
+
941
+ const under = `${prefix}${lastAgent ? " " : "│ "} `;
942
+ const meta = [
943
+ agent.role ? `role ${agent.role}` : "",
944
+ agent.requestedModel && agent.requestedModel !== agent.model
945
+ ? `via ${agent.requestedModel}`
946
+ : "",
947
+ agent.tools?.length
948
+ ? agent.tools.join(" ")
949
+ : agent.toolCalls === undefined
950
+ ? ""
951
+ : `${String(agent.toolCalls)} ${agent.toolCalls === 1 ? "call" : "calls"}`,
952
+ ].filter(Boolean);
953
+ if (meta.length > 0) lines.push(theme.fg("muted", `${under}${meta.join(" · ")}`));
954
+
955
+ const split = [
956
+ agent.input ? `in ${formatTokens(agent.input)}` : "",
957
+ agent.output ? `out ${formatTokens(agent.output)}` : "",
958
+ agent.cacheRead ? `cache ${formatTokens(agent.cacheRead)}` : "",
959
+ ].filter(Boolean);
960
+ if (split.length > 0) lines.push(theme.fg("muted", `${under}${split.join(" · ")}`));
961
+
962
+ const kids = agent.id === undefined ? [] : children.get(agent.id) ?? [];
963
+ kids.forEach((child, childIndex) => {
964
+ drawAgent(child, under, childIndex === kids.length - 1);
965
+ });
966
+ };
967
+
968
+ roots.forEach((agent, agentIndex) => {
969
+ drawAgent(agent, `${stem} `, agentIndex === roots.length - 1);
970
+ });
971
+ });
972
+
973
+ if (data.error !== undefined) lines.push(theme.fg("error", ` ${data.error}`));
974
+
975
+ // The run id is only useful when acting on it (resuming, digging through the
976
+ // run directory), so it stays out of the way until the entry is expanded.
977
+ if (data.runId) lines.push(theme.fg("muted", ` run ${data.runId}`));
978
+
979
+ return lines;
980
+ }
981
+
982
+ export interface BackgroundWidgetController { suspend(): void; resume(): void }
983
+
984
+ export default function widget(pi: BackgroundWidgetAPI, enabled = true): BackgroundWidgetController {
985
+ if (!enabled || typeof pi.registerEntryRenderer !== "function" || typeof pi.registerShortcut !== "function" || !pi.events || typeof pi.events.on !== "function") return { suspend() {}, resume() {} };
986
+ const eventBus = pi.events;
987
+ const subscribe = (name: string, handler: (event: unknown) => void): (() => void) => eventBus.on?.(name, handler) ?? (() => {});
988
+ const registerEntryRenderer = pi.registerEntryRenderer;
989
+ const registerShortcut = pi.registerShortcut;
990
+ let context: ExtensionContext | undefined;
991
+ const isTuiContext = (): boolean => context?.hasUI === true && context.mode === "tui";
992
+ let animationTimer: NodeJS.Timeout | undefined;
993
+ let rescanTimer: NodeJS.Timeout | undefined;
994
+ let requestRender: (() => void) | undefined;
995
+ let suspended = false;
996
+ /** True while a frame is on screen, so it is only cleared when there is one. */
997
+ let showing = false;
998
+ let renderFailed = false;
999
+
1000
+ /**
1001
+ * Whether the widget holds the keyboard, and which row is under the cursor.
1002
+ *
1003
+ * Focus is only taken deliberately — `↓` on an empty editor — and given back
1004
+ * the moment anything is typed. A widget that swallowed keys the rest of the
1005
+ * time would be a widget you had to fight to write past.
1006
+ */
1007
+ let focused = false;
1008
+ /** Rows in the last frame, so the cursor cannot leave it. */
1009
+ let rowCount = 0;
1010
+ /** Keys of rows the reader has folded shut, kept across repaints. */
1011
+ /** How far the window has been scrolled down the tree. */
1012
+ let offset = 0;
1013
+ /** Rows the tree had in the last frame, so scrolling cannot run off its end. */
1014
+ const size = { rows: 0 };
1015
+ /**
1016
+ * The TUI the widget was last built against, kept only to ask whether
1017
+ * something else currently owns the keyboard.
1018
+ */
1019
+ let host: TuiHandle | undefined;
1020
+ let inputUnsubscribe: (() => void) | undefined;
1021
+ /**
1022
+ * Whether some other component is holding the keyboard.
1023
+ *
1024
+ * `↓` from an empty editor is the comfortable way in, but an empty editor is
1025
+ * also what every picker and dialog leaves behind while it waits for arrows.
1026
+ * Claiming the key regardless meant `/workflow` opened a menu that could not
1027
+ * be moved through.
1028
+ *
1029
+ * The editor holds focus whenever nothing special is open, so the question is
1030
+ * whether focus is still on an editor — recognised by what it can do, not by
1031
+ * `instanceof`. A widget is installed beside Pi rather than inside it, and npm
1032
+ * may well give it a second copy of the TUI package: the classes then differ
1033
+ * by identity even though they are the same class, and `instanceof` answers
1034
+ * "not an editor" for the editor itself. Duck typing survives that.
1035
+ */
1036
+ const somethingElseHasTheKeyboard = (): boolean => {
1037
+ try {
1038
+ if (host?.hasOverlay?.()) return true;
1039
+ const focus = host?.getFocusedComponent?.() as { getText?: unknown; addToHistory?: unknown } | null | undefined;
1040
+ if (focus === undefined) return false;
1041
+ if (focus === null) return false;
1042
+ return typeof focus.getText !== "function" || typeof focus.addToHistory !== "function";
1043
+ } catch {
1044
+ // An older TUI without these accessors: assume the coast is clear rather
1045
+ // than disabling navigation outright.
1046
+ return false;
1047
+ }
1048
+ };
1049
+
1050
+ /** Run directories seen this session, and the parsed state of each. */
1051
+ const runs = new Map<string, Run>();
1052
+ /** Last-seen mtime per run, so an unchanged state file is never re-parsed. */
1053
+ const seen = new Map<string, number>();
1054
+ /** Runs already written to the transcript, so each is recorded once. */
1055
+ const receipted = new Set<string>();
1056
+ /** Terminal completed/failed state events wait for their dedicated event. */
1057
+ const awaitingDedicatedReceipt = new Set<string>();
1058
+ const failureEvents = new Map<string, { message: string }>();
1059
+ let sessionGeneration = 0;
1060
+
1061
+ const sessionId = (): string | undefined => context?.sessionManager.getSessionId();
1062
+ const resetNavigation = (): void => {
1063
+ focused = false;
1064
+ offset = 0;
1065
+ rowCount = 0;
1066
+ size.rows = 0;
1067
+ host = undefined;
1068
+ };
1069
+ const stopAnimationTimer = (): void => {
1070
+ if (animationTimer !== undefined) clearInterval(animationTimer);
1071
+ animationTimer = undefined;
1072
+ };
1073
+ const stopRescanTimer = (): void => {
1074
+ if (rescanTimer !== undefined) clearInterval(rescanTimer);
1075
+ rescanTimer = undefined;
1076
+ };
1077
+ const hide = (): void => {
1078
+ stopAnimationTimer();
1079
+ stopRescanTimer();
1080
+ requestRender = undefined;
1081
+ if (showing) {
1082
+ try { context?.ui.setWidget(KEY, undefined); } catch { /* A failed repaint is already being cleared. */ }
1083
+ }
1084
+ showing = false;
1085
+ renderFailed = false;
1086
+ inputUnsubscribe?.();
1087
+ inputUnsubscribe = undefined;
1088
+ resetNavigation();
1089
+ };
1090
+ const handleInput = (data: string): { consume?: boolean } | undefined => {
1091
+ if (!showing || !focused) return undefined;
1092
+ if (somethingElseHasTheKeyboard()) {
1093
+ focused = false;
1094
+ offset = 0;
1095
+ return undefined;
1096
+ }
1097
+ if (maxOffset(rowCount, MAX_ROWS - 2) === 0) {
1098
+ focused = false;
1099
+ offset = 0;
1100
+ return undefined;
1101
+ }
1102
+ if (isKeyRelease(data)) return { consume: true };
1103
+ if (matchesKey(data, Key.up)) {
1104
+ offset = Math.max(0, offset - 1);
1105
+ requestRender?.();
1106
+ return { consume: true };
1107
+ }
1108
+ if (matchesKey(data, Key.down)) {
1109
+ offset = Math.min(maxOffset(rowCount, MAX_ROWS - 2), offset + 1);
1110
+ requestRender?.();
1111
+ return { consume: true };
1112
+ }
1113
+ if (matchesKey(data, Key.escape) || data === "q") {
1114
+ focused = false;
1115
+ offset = 0;
1116
+ requestRender?.();
1117
+ return { consume: true };
1118
+ }
1119
+ focused = false;
1120
+ offset = 0;
1121
+ return undefined;
1122
+ };
1123
+ const installInput = (): void => {
1124
+ if (!showing || !__navigationForTests.enabled || inputUnsubscribe || !isTuiContext()) return;
1125
+ inputUnsubscribe = context?.ui.onTerminalInput(handleInput);
1126
+ };
1127
+
1128
+
1129
+ /** Re-read one run's state. Called on events, never on the animation timer. */
1130
+ const refresh = (runId: string, directory: string, runSessionId: string): void => {
1131
+ // A run filed under another session belongs to another window's widget.
1132
+ if (runSessionId !== sessionId() || receipted.has(runId)) return;
1133
+ const run = readRun(directory);
1134
+ if (!run) return;
1135
+ // A pending checkpoint is known only from events, so it has to survive a
1136
+ // re-read of the state file that knows nothing about it.
1137
+ const waiting = runs.get(runId)?.waiting;
1138
+ runs.set(runId, waiting ? { ...run, waiting } : run);
1139
+ };
1140
+
1141
+ const receipt = (runId: string): void => {
1142
+ if (!isTuiContext() || receipted.has(runId)) return;
1143
+ const current = runs.get(runId);
1144
+ if (!current) return;
1145
+ // Events are signals, not the receipt's data. Read the final atomic state
1146
+ // again so a completion event cannot capture the preceding running state.
1147
+ const persisted = readRun(current.directory);
1148
+ if (!persisted || !isRunTerminal(persisted.state)) return;
1149
+ const run = current.waiting ? { ...persisted, waiting: current.waiting } : persisted;
1150
+ receipted.add(runId);
1151
+ runs.delete(runId);
1152
+ seen.delete(runId);
1153
+ const failure = failureEvents.get(runId);
1154
+ failureEvents.delete(runId);
1155
+ // A foreground run already left its own summary in the transcript, put
1156
+ // there by the workflow tool call that waited for it. A second account of
1157
+ // the same run directly beneath the first is noise.
1158
+ if (run.delivery?.mode === "foreground") return;
1159
+ pi.appendEntry<Receipt>(ENTRY_TYPE, receiptFor(run, failure));
1160
+ };
1161
+
1162
+
1163
+ const hasVisibleRun = (): boolean => {
1164
+ for (const run of runs.values()) {
1165
+ if (isRunLive(run.state) && run.delivery?.mode !== "foreground") return true;
1166
+ }
1167
+ return false;
1168
+ };
1169
+ const needsAnimation = (): boolean => {
1170
+ for (const run of runs.values()) {
1171
+ if (run.state === "running" && run.delivery?.mode !== "foreground") return true;
1172
+ }
1173
+ return false;
1174
+ };
1175
+ const startTimers = (): void => {
1176
+ if (!showing || suspended || !isTuiContext()) return;
1177
+ if (rescanTimer === undefined) {
1178
+ // Rescans also refresh elapsed clocks for visible runs without a spinner.
1179
+ rescanTimer = setInterval(() => { tick(true); }, RESCAN_MS);
1180
+ rescanTimer.unref();
1181
+ }
1182
+ const animate = needsAnimation();
1183
+ if (animate && animationTimer === undefined) {
1184
+ animationTimer = setInterval(() => {
1185
+ try {
1186
+ if (renderFailed || !showing || !needsAnimation()) {
1187
+ stopAnimationTimer();
1188
+ return;
1189
+ }
1190
+ requestRender?.();
1191
+ } catch {
1192
+ hide();
1193
+ }
1194
+ }, REPAINT_MS);
1195
+ animationTimer.unref();
1196
+ } else if (!animate) {
1197
+ stopAnimationTimer();
1198
+ }
1199
+ };
1200
+ const paint = (stateChanged = false): void => {
1201
+ if (suspended || !isTuiContext()) {
1202
+ hide();
1203
+ return;
1204
+ }
1205
+ if (renderFailed || !hasVisibleRun()) {
1206
+ hide();
1207
+ return;
1208
+ }
1209
+ if (showing) {
1210
+ startTimers();
1211
+ if (stateChanged) requestRender?.();
1212
+ return;
1213
+ }
1214
+
1215
+ // Registered as a factory so the frame is built at the width the TUI is
1216
+ // about to draw with, read in the same pass that validates it. Reading the
1217
+ // terminal separately races a pane split: the frame is built at one width
1218
+ // and checked against another, and Pi treats an over-wide line as fatal
1219
+ // rather than wrapping it. Every row is clamped again on the way out, so
1220
+ // the worst case is a clipped row instead of a crashed session.
1221
+ // Below the editor, where a running job belongs: it is something to
1222
+ // glance down at, not something standing between the conversation and
1223
+ // the place you type.
1224
+ showing = true;
1225
+ try {
1226
+ context?.ui.setWidget(
1227
+ KEY,
1228
+ (tui: TuiHandle, theme: Theme) => {
1229
+ // Held so the input handler can ask who owns the keyboard right now.
1230
+ host = tui;
1231
+ requestRender = () => { tui.requestRender?.(); };
1232
+ return {
1233
+ render: (width: number): string[] => {
1234
+ try {
1235
+ // The layout is composed against a floor of twenty columns, because
1236
+ // a tree drawn narrower than that is unreadable anyway — but what
1237
+ // leaves this function is measured against the width Pi actually
1238
+ // gave. A pane narrower than the floor gets a cut frame; it does
1239
+ // not get a crash, and pi-tui treats an over-wide line as fatal.
1240
+ const usable = Math.max(20, width);
1241
+ const frame = renderFrame([...runs.values()], Date.now(), usable, focused ? offset : 0, theme, focused, size);
1242
+ if (frame === undefined) {
1243
+ renderFailed = true;
1244
+ inputUnsubscribe?.();
1245
+ inputUnsubscribe = undefined;
1246
+ resetNavigation();
1247
+ return [];
1248
+ }
1249
+ // The keys track the body rows exactly, so they say how far the
1250
+ // cursor may travel — the lid and the floor are not rows to land on.
1251
+ rowCount = size.rows;
1252
+ offset = Math.min(offset, maxOffset(rowCount, MAX_ROWS - 2));
1253
+ return frame.map((line) =>
1254
+ visibleLength(line) > width ? truncate(line, width) : line,
1255
+ );
1256
+ } catch {
1257
+ renderFailed = true;
1258
+ inputUnsubscribe?.();
1259
+ inputUnsubscribe = undefined;
1260
+ resetNavigation();
1261
+ return [];
1262
+ }
1263
+ },
1264
+ invalidate: (): void => {
1265
+ // Nothing is cached between frames; every render reads current state.
1266
+ },
1267
+ };
1268
+ },
1269
+ { placement: "belowEditor" },
1270
+ );
1271
+ installInput();
1272
+ startTimers();
1273
+ } catch {
1274
+ hide();
1275
+ }
1276
+ };
1277
+
1278
+ /**
1279
+ * Re-read any run whose state file changed since the last look.
1280
+ *
1281
+ * Events cover agents and phases, but shell activity is written to the state
1282
+ * file without one: a phase running nothing but `shell` would sit unchanged
1283
+ * on screen until some unrelated event happened to arrive. A stat per live
1284
+ * run is cheap next to parsing one — the check costs a few microseconds and
1285
+ * only the changed file is read.
1286
+ */
1287
+ const rescan = (): boolean => {
1288
+ let changed = false;
1289
+ for (const [runId, run] of runs) {
1290
+ if (receipted.has(runId)) {
1291
+ runs.delete(runId);
1292
+ seen.delete(runId);
1293
+ changed = true;
1294
+ continue;
1295
+ }
1296
+ try {
1297
+ const mtime = statSync(join(run.directory, "state.json")).mtimeMs;
1298
+ if (mtime === seen.get(runId)) continue;
1299
+ seen.set(runId, mtime);
1300
+ const fresh = readRun(run.directory);
1301
+ if (fresh) {
1302
+ runs.set(runId, run.waiting ? { ...fresh, waiting: run.waiting } : fresh);
1303
+ changed = true;
1304
+ if (isRunTerminal(fresh.state) && !awaitingDedicatedReceipt.has(runId)) receipt(runId);
1305
+ }
1306
+ } catch {
1307
+ // The run's directory went away, or is mid-write. Keep what we have.
1308
+ }
1309
+ }
1310
+ return changed;
1311
+ };
1312
+
1313
+ function tick(stateChanged = false): void {
1314
+ try {
1315
+ const rescanned = rescan();
1316
+ paint(stateChanged || rescanned);
1317
+ } catch {
1318
+ // A failed repaint is a skipped frame, never fatal: a widget that dies
1319
+ // quietly is indistinguishable from one that was never drawn.
1320
+ hide();
1321
+ }
1322
+ }
1323
+
1324
+ // The transcript entry: appendEntry carries the data, this renders it. It
1325
+ // returns a plain component rather than importing pi-tui's Text, because
1326
+ // @earendil-works/pi-tui only resolves inside Pi's own module tree.
1327
+ registerEntryRenderer<Receipt>(ENTRY_TYPE, (entry, options, theme) => {
1328
+ if (!entry.data) return undefined;
1329
+ const lines = renderReceipt(entry.data, options.expanded, theme);
1330
+ return {
1331
+ // The width has to be honoured here as much as in the widget: Pi treats
1332
+ // an over-wide line as fatal rather than wrapping it, and a receipt can
1333
+ // carry a long line — a toolbox listing, a failure message — that no
1334
+ // amount of care at build time can bound.
1335
+ render: (width: number): string[] =>
1336
+ lines.map((line) => (visibleLength(line) > width ? truncate(line, width) : line)),
1337
+ invalidate: (): undefined => undefined,
1338
+ };
1339
+ });
1340
+
1341
+ const onRunEvent = (event: unknown, terminal = false, deferReceipt = false): void => {
1342
+ const { runId, runDirectory, sessionId: eventSession, error } = event as {
1343
+ runId?: string;
1344
+ runDirectory?: string;
1345
+ sessionId?: string;
1346
+ error?: { message?: unknown };
1347
+ };
1348
+ if (!isTuiContext() || !runId || !runDirectory || !eventSession || eventSession !== sessionId()) return;
1349
+ if (receipted.has(runId)) return;
1350
+ if (deferReceipt) awaitingDedicatedReceipt.add(runId);
1351
+ if (terminal) awaitingDedicatedReceipt.delete(runId);
1352
+ if (typeof error?.message === "string") failureEvents.set(runId, { message: error.message });
1353
+ refresh(runId, runDirectory, eventSession);
1354
+ if (terminal) receipt(runId);
1355
+ tick(true);
1356
+ };
1357
+
1358
+ /** Unsubscribe callbacks, so this instance can leave the bus as it found it. */
1359
+ const unsubscribes: (() => void)[] = [];
1360
+
1361
+ for (const name of [
1362
+ WORKFLOW_RUN_STARTED_EVENT,
1363
+ WORKFLOW_AGENT_STATE_CHANGED_EVENT,
1364
+ WORKFLOW_PHASE_CHANGED_EVENT,
1365
+ WORKFLOW_BUDGET_EVENT,
1366
+ ]) {
1367
+ unsubscribes.push(subscribe(name, onRunEvent));
1368
+ }
1369
+
1370
+ unsubscribes.push(subscribe(WORKFLOW_RUN_STATE_CHANGED_EVENT, (event: unknown) => {
1371
+ const state = (event as { state?: unknown }).state;
1372
+ onRunEvent(event, state === "stopped", state === "completed" || state === "failed");
1373
+ }));
1374
+ for (const name of [WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT]) {
1375
+ unsubscribes.push(subscribe(name, (event: unknown) => {
1376
+ onRunEvent(event, true);
1377
+ }));
1378
+ }
1379
+ unsubscribes.push(
1380
+ subscribe(WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, (event: unknown) => {
1381
+ const { runId, runDirectory, sessionId: eventSession, name, state } = event as {
1382
+ runId?: string;
1383
+ runDirectory?: string;
1384
+ sessionId?: string;
1385
+ name?: string;
1386
+ state?: string;
1387
+ };
1388
+ if (!isTuiContext() || !runId || !runDirectory || !eventSession || eventSession !== sessionId()) return;
1389
+ refresh(runId, runDirectory, eventSession);
1390
+ const run = runs.get(runId);
1391
+ if (!run) return;
1392
+ if (state === "awaiting") {
1393
+ runs.set(runId, { ...run, waiting: { name: name ?? "checkpoint", since: Date.now() } });
1394
+ } else {
1395
+ // Drop the key entirely rather than setting it undefined: the package
1396
+ // builds with exactOptionalPropertyTypes.
1397
+ const answered = { ...run };
1398
+ delete answered.waiting;
1399
+ runs.set(runId, answered);
1400
+ }
1401
+ tick(true);
1402
+ }),
1403
+ );
1404
+
1405
+
1406
+ const suspend = (): void => {
1407
+ suspended = true;
1408
+ hide();
1409
+ };
1410
+ const resume = (): void => {
1411
+ if (!suspended) return;
1412
+ suspended = false;
1413
+ tick(true);
1414
+ };
1415
+
1416
+ const stop = (): void => {
1417
+ sessionGeneration += 1;
1418
+ failureEvents.clear();
1419
+ hide();
1420
+ context = undefined;
1421
+ runs.clear();
1422
+ seen.clear();
1423
+ receipted.clear();
1424
+ awaitingDedicatedReceipt.clear();
1425
+ for (const unsubscribe of unsubscribes.splice(0)) unsubscribe();
1426
+ };
1427
+
1428
+ const reconcile = async (sessionContext: ExtensionContext, generation: number): Promise<void> => {
1429
+ if (typeof sessionContext.cwd !== "string" || !sessionContext.cwd) {
1430
+ if (generation === sessionGeneration) tick();
1431
+ return;
1432
+ }
1433
+ const currentSessionId = sessionContext.sessionManager.getSessionId();
1434
+ const runIds = await listRunIds(sessionContext.cwd, currentSessionId);
1435
+ if (generation !== sessionGeneration || context !== sessionContext) return;
1436
+ for (const runId of runIds) {
1437
+ if (generation !== sessionGeneration || context !== sessionContext) return;
1438
+ if (receipted.has(runId)) continue;
1439
+ const directory = new RunStore(sessionContext.cwd, currentSessionId, runId).directory;
1440
+ const run = readRun(directory);
1441
+ if (!run || run.sessionId !== currentSessionId || run.delivery?.mode === "foreground") continue;
1442
+ const waiting = runs.get(runId)?.waiting;
1443
+ runs.set(runId, waiting ? { ...run, waiting } : run);
1444
+ try { seen.set(runId, statSync(join(directory, "state.json")).mtimeMs); } catch { /* Retry on the next event if the state is mid-write. */ }
1445
+ if (isRunTerminal(run.state) && !awaitingDedicatedReceipt.has(runId)) receipt(runId);
1446
+ }
1447
+ if (generation === sessionGeneration) tick();
1448
+ };
1449
+
1450
+ pi.on("session_start", (_event, sessionContext) => {
1451
+ context = sessionContext;
1452
+ const generation = ++sessionGeneration;
1453
+ hide();
1454
+ failureEvents.clear();
1455
+ runs.clear();
1456
+ seen.clear();
1457
+ receipted.clear();
1458
+ awaitingDedicatedReceipt.clear();
1459
+ try {
1460
+ for (const entry of sessionContext.sessionManager.getEntries()) {
1461
+ const custom = entry as { type?: string; customType?: string; data?: { runId?: string } };
1462
+ if (custom.type !== "custom" || custom.customType !== ENTRY_TYPE) continue;
1463
+ if (custom.data?.runId) receipted.add(custom.data.runId);
1464
+ }
1465
+ } catch {
1466
+ // No history to read; runs from this session on are recorded anyway.
1467
+ }
1468
+ if (!isTuiContext()) return;
1469
+
1470
+
1471
+ tick();
1472
+ void reconcile(sessionContext, generation).catch(() => {
1473
+ if (generation === sessionGeneration) tick();
1474
+ });
1475
+ });
1476
+
1477
+ // A way in that never competes. `↓` is the comfortable gesture but it is
1478
+ // shared with every picker; the shortcut answers whatever else is on screen,
1479
+ // and Pi lets it be rebound in `keybindings.json` like any other.
1480
+ if (__navigationForTests.enabled) registerShortcut(FOCUS_SHORTCUT, {
1481
+ description: "Scroll the workflow widget",
1482
+ handler: () => {
1483
+ if (!showing || maxOffset(rowCount, MAX_ROWS - 2) === 0) {
1484
+ focused = false;
1485
+ offset = 0;
1486
+ return;
1487
+ }
1488
+ focused = !focused;
1489
+ offset = 0;
1490
+ requestRender?.();
1491
+ },
1492
+ });
1493
+
1494
+ pi.on("session_shutdown", stop);
1495
+ return { suspend, resume };
1496
+ }