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