@chrok/pi-braid 0.3.2 → 0.3.3
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/README.md +37 -6
- package/dist/command.js +186 -37
- package/dist/display.js +150 -18
- package/dist/index.js +17 -3
- package/dist/jobs.js +32 -3
- package/dist/runner.js +90 -21
- package/dist/session-view.js +112 -0
- package/dist/transcript.js +100 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -225,11 +225,39 @@ Use Left/Right to select jobs, Up/Down or Page Up/Page Down to scroll, `c` to
|
|
|
225
225
|
cancel the selected job, and Escape or `q` to close the panel. Closing the panel
|
|
226
226
|
leaves jobs running. In RPC or noninteractive modes, use `braid_status`.
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
or
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
Press
|
|
228
|
+
A flowchart wider than the terminal is cropped to the terminal width around the
|
|
229
|
+
first running node, or from its left edge; a `[chart 1–76 of 245 cols]` line
|
|
230
|
+
shows the visible columns.
|
|
231
|
+
|
|
232
|
+
Press `v` to select a node: the arrow keys move a `◆` marker through the
|
|
233
|
+
flowchart (starting at a running node), and the view pans horizontally and
|
|
234
|
+
scrolls vertically to keep the selected node in sight. If the flowchart cannot be
|
|
235
|
+
drawn, a node list replaces it while selecting. Enter opens that node's latest
|
|
236
|
+
invocation as a live session. The session is drawn with Pi's own chat
|
|
237
|
+
components, as in the main conversation: the worker's prompt, streamed
|
|
238
|
+
assistant text and thinking, and tool cards whose arguments and output update
|
|
239
|
+
while they run. The node summary—status, elapsed time, usage, context, and live
|
|
240
|
+
activity (see below)—stays pinned at the top while the session scrolls. The
|
|
241
|
+
session follows the newest lines; scroll up to pause, and press End to follow again. Left/Right switch between
|
|
242
|
+
invocations of the same node (for example loop iterations), and ctrl+o expands
|
|
243
|
+
tool output. Escape steps back one level—session, then node selection, then the
|
|
244
|
+
graph—and closes the panel from the graph; `q` closes it from anywhere. Sessions
|
|
245
|
+
are read-only. Running invocations and the 20 most recently finished ones per
|
|
246
|
+
job keep their session; older ones show the summary and activity history.
|
|
247
|
+
|
|
248
|
+
Run `/braid <jobId> <nodeId>` to open that node's latest invocation directly in
|
|
249
|
+
the session view, or `/braid <jobId> <executionId>` for an exact invocation such
|
|
250
|
+
as an earlier loop iteration. The header shows live activity for that exact invocation: the current phase
|
|
251
|
+
(workspace preparation, worker, model request, tool, or checkpoint/cleanup) and
|
|
252
|
+
how long it has been in it, time since the last observed signal, total time, the
|
|
253
|
+
in-flight model request (sent, awaiting the first stream event, or streaming
|
|
254
|
+
text/reasoning/tool-call arguments with a character count and latest tail), and
|
|
255
|
+
running tools with their arguments, elapsed time, and latest output. Focused
|
|
256
|
+
`braid_status` reads, and invocations whose session is no longer retained, add a
|
|
257
|
+
chronological history of model requests and responses, assistant text, tool calls
|
|
258
|
+
and results, workspace changes, and completion, with bounded detail previews.
|
|
259
|
+
A quiet period is shown as elapsed time, not declared a stall. If the host cannot
|
|
260
|
+
report a signal—for example a model registry without streaming—the view says so.
|
|
233
261
|
Tab completes job handles and then that job's node IDs.
|
|
234
262
|
IDs with leading/trailing whitespace, terminal controls, or a leading double
|
|
235
263
|
quote use JSON string syntax, such as `/braid job-1 " review "` or
|
|
@@ -241,7 +269,10 @@ estimates or provider-reported usage, context-window sizes, filesystem tool-call
|
|
|
241
269
|
counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status
|
|
242
270
|
tool also renders a flowchart; expand its result to see more log events.
|
|
243
271
|
When selecting `nodeId` or `executionId`, it instead shows that invocation's
|
|
244
|
-
output/error, with the full text available on expansion.
|
|
272
|
+
output/error and activity, with the full text and history available on expansion.
|
|
273
|
+
The JSON includes `activity` with the latest 40 history entries; pass
|
|
274
|
+
`activityBefore` with an entry sequence to page earlier ones. While the invocation
|
|
275
|
+
runs, `activity` comes before `node` so it survives preview truncation.
|
|
245
276
|
|
|
246
277
|
`/braid` now opens this panel; it no longer arms the next prompt. To request
|
|
247
278
|
Braid explicitly, ask the agent to analyze the task using Braid.
|
package/dist/command.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
2
|
-
import { renderGraphResult,
|
|
2
|
+
import { renderGraphResult, renderNodeDetails, renderSessionHeader, SELECTED_NODE_MARK } from "./display.js";
|
|
3
|
+
import { NodeSessionView } from "./session-view.js";
|
|
3
4
|
// Match the overlay height exactly so Pi never clips the footer or bottom border.
|
|
4
5
|
const panelHeight = (rows) => Math.max(1, Math.floor(rows * 0.9));
|
|
5
6
|
const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* A live, scrollable panel. Esc steps back one level: session → node selection →
|
|
9
|
+
* graph → close. Closing the panel leaves jobs running.
|
|
10
|
+
*/
|
|
7
11
|
export class BraidPanel {
|
|
8
12
|
jobs;
|
|
9
13
|
tui;
|
|
@@ -11,9 +15,16 @@ export class BraidPanel {
|
|
|
11
15
|
done;
|
|
12
16
|
selected;
|
|
13
17
|
focus;
|
|
18
|
+
/** Node highlighted in selection mode (`v`); undefined in the plain graph view. */
|
|
19
|
+
choosing;
|
|
20
|
+
/** Keep the newest transcript lines in view until the user scrolls up. */
|
|
21
|
+
follow = true;
|
|
22
|
+
/** The selection last scrolled into view, so manual scrolling is not overridden. */
|
|
23
|
+
revealed;
|
|
14
24
|
offset = 0;
|
|
15
25
|
maxOffset = 0;
|
|
16
26
|
disposed = false;
|
|
27
|
+
session;
|
|
17
28
|
unsubscribe;
|
|
18
29
|
ticker;
|
|
19
30
|
constructor(jobs, tui, theme, done, jobId, focus) {
|
|
@@ -23,6 +34,8 @@ export class BraidPanel {
|
|
|
23
34
|
this.done = done;
|
|
24
35
|
this.selected = jobId ? jobs.get(jobId)?.jobId ?? jobId : undefined;
|
|
25
36
|
this.focus = focus;
|
|
37
|
+
// Pi's tool components only request renders through the TUI.
|
|
38
|
+
this.session = new NodeSessionView(tui, theme);
|
|
26
39
|
this.unsubscribe = jobs.subscribe(() => this.refresh());
|
|
27
40
|
this.ticker = setInterval(() => this.refresh(), 1000);
|
|
28
41
|
this.ticker.unref();
|
|
@@ -64,7 +77,7 @@ export class BraidPanel {
|
|
|
64
77
|
: "muted";
|
|
65
78
|
const focusLabel = this.focus
|
|
66
79
|
? "nodeId" in this.focus ? `node ${plain(this.focus.nodeId)}` : `execution ${plain(this.focus.executionId)}`
|
|
67
|
-
: "";
|
|
80
|
+
: this.choosing !== undefined ? "select a node" : "";
|
|
68
81
|
const heading = current
|
|
69
82
|
? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}${focusLabel ? ` · ${focusLabel}` : ""}`
|
|
70
83
|
: this.theme.fg("muted", "No background jobs");
|
|
@@ -92,8 +105,19 @@ export class BraidPanel {
|
|
|
92
105
|
: "Jobs will appear here as soon as they are submitted.")),
|
|
93
106
|
rule("├", "┤"),
|
|
94
107
|
];
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
if (current && this.choosing !== undefined && !this.nodeIds(current).includes(this.choosing)) {
|
|
109
|
+
this.choosing = this.nodeIds(current)[0];
|
|
110
|
+
}
|
|
111
|
+
// The node summary (or the selected node) stays pinned while the body scrolls.
|
|
112
|
+
const focused = current && this.focus ? this.renderFocus(current, this.focus, contentWidth) : undefined;
|
|
113
|
+
let pinned = focused?.pinned ?? (current && this.choosing !== undefined ? [this.choosingLine(current)] : []);
|
|
114
|
+
const available = rows - header.length - 4;
|
|
115
|
+
const maxPinned = Math.max(2, Math.floor(available / 2));
|
|
116
|
+
if (pinned.length > maxPinned) {
|
|
117
|
+
pinned = [...pinned.slice(0, maxPinned - 1), this.theme.fg("dim", `… ${pinned.length - maxPinned + 1} more summary lines`)];
|
|
118
|
+
}
|
|
119
|
+
const content = focused
|
|
120
|
+
? focused.body
|
|
97
121
|
: current
|
|
98
122
|
? [
|
|
99
123
|
...(current.error
|
|
@@ -106,29 +130,38 @@ export class BraidPanel {
|
|
|
106
130
|
...(current.fullOutputPath
|
|
107
131
|
? { fullOutputPath: current.fullOutputPath }
|
|
108
132
|
: {}),
|
|
109
|
-
}, true, false, this.theme).render(contentWidth),
|
|
133
|
+
}, true, false, this.theme, "", false, this.choosing).render(contentWidth),
|
|
110
134
|
]
|
|
111
135
|
: ["No Braid jobs in this session."];
|
|
112
|
-
const height =
|
|
136
|
+
const height = available - (pinned.length ? pinned.length + 1 : 0);
|
|
113
137
|
this.maxOffset = Math.max(0, content.length - height);
|
|
114
|
-
this.offset = Math.min(this.offset, this.maxOffset);
|
|
138
|
+
this.offset = this.focus && this.follow ? this.maxOffset : Math.min(this.offset, this.maxOffset);
|
|
139
|
+
if (!this.focus && this.choosing !== undefined && this.choosing !== this.revealed) {
|
|
140
|
+
// Bring a newly selected node's box into view in a tall flowchart.
|
|
141
|
+
this.revealed = this.choosing;
|
|
142
|
+
const line = content.findIndex(text => stripTerminalSequences(text).includes(SELECTED_NODE_MARK));
|
|
143
|
+
if (line >= 0 && (line < this.offset || line > this.offset + height - 3)) {
|
|
144
|
+
this.offset = Math.max(0, Math.min(this.maxOffset, line - Math.floor(height / 3)));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (this.choosing === undefined)
|
|
148
|
+
this.revealed = undefined;
|
|
115
149
|
const body = content.slice(this.offset, this.offset + height);
|
|
116
150
|
while (body.length < height)
|
|
117
151
|
body.push("");
|
|
118
152
|
const range = `Lines ${this.offset + 1}–${Math.min(content.length, this.offset + height)}/${content.length}`;
|
|
119
|
-
const
|
|
120
|
-
?
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
? "←/→ jobs · ↑/↓ scroll · c cancel · Esc close"
|
|
129
|
-
: "↑/↓ scroll · Esc close";
|
|
153
|
+
const [wide, medium, narrow] = this.focus
|
|
154
|
+
? ["Esc back ←/→ executions ↑/↓ scroll End follow ctrl+o tools c cancel q close",
|
|
155
|
+
"Esc back · ←/→ executions · ↑/↓ scroll · q close", "Esc back · q close"]
|
|
156
|
+
: this.choosing !== undefined
|
|
157
|
+
? ["↑/↓/←/→ choose node Enter open session Esc back PgUp/PgDn page q close",
|
|
158
|
+
"↑/↓ choose · Enter open · Esc back", "Enter open · Esc back"]
|
|
159
|
+
: ["v select node ←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close",
|
|
160
|
+
"v select node · ←/→ jobs · ↑/↓ scroll · Esc close", "v select · Esc close"];
|
|
161
|
+
const help = contentWidth >= 80 ? wide : contentWidth >= 42 ? medium : narrow;
|
|
130
162
|
return [
|
|
131
163
|
...header,
|
|
164
|
+
...(pinned.length ? [...pinned.map(row), rule("├", "┤")] : []),
|
|
132
165
|
...body.map(row),
|
|
133
166
|
rule("├", "┤"),
|
|
134
167
|
row(this.theme.fg("dim", `${range}${contentWidth >= 60 ? " · Jobs keep running when this panel closes" : ""}`)),
|
|
@@ -136,6 +169,17 @@ export class BraidPanel {
|
|
|
136
169
|
rule("╰", "╯"),
|
|
137
170
|
];
|
|
138
171
|
}
|
|
172
|
+
/** Graph definition order, which is also the flowchart's declaration order. */
|
|
173
|
+
nodeIds(job) {
|
|
174
|
+
return Object.keys(job.result?.nodes ?? job.live.nodes);
|
|
175
|
+
}
|
|
176
|
+
choosingLine(job) {
|
|
177
|
+
const nodes = job.result?.nodes ?? job.live.nodes;
|
|
178
|
+
const node = this.choosing !== undefined ? nodes[this.choosing] : undefined;
|
|
179
|
+
const ids = this.nodeIds(job);
|
|
180
|
+
return this.theme.fg("warning", `${SELECTED_NODE_MARK} Selected ${ids.indexOf(this.choosing ?? "") + 1}/${ids.length}: `) +
|
|
181
|
+
this.theme.bold(plain(this.choosing ?? "")) + this.theme.fg("muted", ` · ${node?.status ?? "pending"} · Enter to open its session`);
|
|
182
|
+
}
|
|
139
183
|
/** A focused node can disappear after a live graph update; say so instead of failing. */
|
|
140
184
|
renderFocus(job, focus, width) {
|
|
141
185
|
let node;
|
|
@@ -145,39 +189,134 @@ export class BraidPanel {
|
|
|
145
189
|
: this.jobs.getNode(job.jobId, undefined, focus.executionId);
|
|
146
190
|
}
|
|
147
191
|
catch {
|
|
148
|
-
return [
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
192
|
+
return { pinned: [], body: [
|
|
193
|
+
this.theme.fg("warning", "This node or execution is no longer in the job's graph."),
|
|
194
|
+
this.theme.fg("muted", "Press Esc to return to the graph."),
|
|
195
|
+
] };
|
|
152
196
|
}
|
|
153
197
|
// Latest-node results omit the loop iteration; their execution record has it.
|
|
154
198
|
const executions = job.result?.executions ?? job.execution?.executions;
|
|
155
199
|
const iteration = node.iteration ??
|
|
156
200
|
(node.executionId ? executions?.[node.executionId]?.iteration : undefined);
|
|
157
|
-
|
|
158
|
-
|
|
201
|
+
const withIteration = iteration === undefined ? node : { ...node, iteration };
|
|
202
|
+
const activity = node.executionId ? this.jobs.getActivity(job.jobId, node.executionId) : undefined;
|
|
203
|
+
const transcript = node.executionId ? this.jobs.getTranscript(job.jobId, node.executionId) : undefined;
|
|
204
|
+
const siblings = this.executionsOf(job, node.id);
|
|
205
|
+
const position = node.executionId ? siblings.indexOf(node.executionId) : -1;
|
|
206
|
+
const header = [
|
|
207
|
+
...renderSessionHeader(withIteration, this.theme, job.live.observedAt, job.live.progress[node.id], activity),
|
|
208
|
+
...(siblings.length > 1 ? [this.theme.fg("dim", `execution ${position + 1} of ${siblings.length} for this node · ←/→ to switch`)] : []),
|
|
209
|
+
];
|
|
210
|
+
let failure;
|
|
211
|
+
if (transcript) {
|
|
212
|
+
try {
|
|
213
|
+
return { pinned: header, body: this.session.render(transcript, width) };
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
// A rendering failure must not take down the overlay; fall back to the summary.
|
|
217
|
+
failure = this.theme.fg("error", `Session view unavailable: ${plain(error instanceof Error ? error.message : String(error))}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Pending, recorded before this session, or evicted: show details and history instead.
|
|
221
|
+
const note = failure ?? (node.executionId
|
|
222
|
+
? this.theme.fg("muted", "The live session for this execution is no longer retained; showing its details and activity history.")
|
|
223
|
+
: this.theme.fg("muted", "This node has not run yet."));
|
|
224
|
+
return { pinned: header, body: [note, ...renderNodeDetails(node, this.theme, job.live.observedAt, activity)] };
|
|
225
|
+
}
|
|
226
|
+
executionsOf(job, nodeId) {
|
|
227
|
+
const executions = job.result?.executions ?? job.execution?.executions ?? {};
|
|
228
|
+
return Object.values(executions).filter(execution => execution.id === nodeId).map(execution => execution.executionId);
|
|
229
|
+
}
|
|
230
|
+
open(job, nodeId) {
|
|
231
|
+
const executionId = this.executionsOf(job, nodeId).at(-1);
|
|
232
|
+
this.focus = executionId ? { executionId } : { nodeId };
|
|
233
|
+
this.follow = true;
|
|
234
|
+
this.offset = 0;
|
|
159
235
|
}
|
|
160
236
|
handleInput(data) {
|
|
161
|
-
if (matchesKey(data, "
|
|
162
|
-
|
|
163
|
-
matchesKey(data, "ctrl+c")) {
|
|
164
|
-
this.dispose();
|
|
165
|
-
this.done();
|
|
237
|
+
if (data === "q" || matchesKey(data, "ctrl+c")) {
|
|
238
|
+
this.close();
|
|
166
239
|
return;
|
|
167
240
|
}
|
|
168
241
|
const jobs = this.jobs.list();
|
|
169
242
|
const index = Math.max(0, jobs.findIndex((job) => job.jobId === this.selected));
|
|
170
|
-
|
|
243
|
+
const job = this.selected ? this.jobs.get(this.selected) : undefined;
|
|
244
|
+
if (this.focus)
|
|
245
|
+
this.sessionInput(data, job);
|
|
246
|
+
else if (this.choosing !== undefined && job)
|
|
247
|
+
this.chooseInput(data, job);
|
|
248
|
+
else if (matchesKey(data, "escape")) {
|
|
249
|
+
this.close();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
else if (data === "v" && job) {
|
|
253
|
+
const ids = this.nodeIds(job);
|
|
254
|
+
this.choosing = ids.find(id => (job.result?.nodes ?? job.live.nodes)[id]?.status === "running") ?? ids[0];
|
|
255
|
+
}
|
|
256
|
+
else if (matchesKey(data, "left") || matchesKey(data, "right")) {
|
|
171
257
|
const step = matchesKey(data, "right") ? 1 : -1;
|
|
172
258
|
this.selected = jobs[(index + step + jobs.length) % jobs.length]?.jobId;
|
|
173
|
-
this.focus = undefined;
|
|
174
259
|
this.offset = 0;
|
|
175
260
|
}
|
|
176
|
-
else
|
|
261
|
+
else
|
|
262
|
+
this.scroll(data);
|
|
263
|
+
if (data === "c" && this.selected)
|
|
264
|
+
this.jobs.cancel(this.selected);
|
|
265
|
+
this.refresh();
|
|
266
|
+
}
|
|
267
|
+
chooseInput(data, job) {
|
|
268
|
+
const ids = this.nodeIds(job);
|
|
269
|
+
const at = Math.max(0, ids.indexOf(this.choosing));
|
|
270
|
+
if (matchesKey(data, "escape") || matchesKey(data, "backspace"))
|
|
271
|
+
this.choosing = undefined;
|
|
272
|
+
else if (matchesKey(data, "up") || matchesKey(data, "left"))
|
|
273
|
+
this.choosing = ids[(at - 1 + ids.length) % ids.length];
|
|
274
|
+
else if (matchesKey(data, "down") || matchesKey(data, "right"))
|
|
275
|
+
this.choosing = ids[(at + 1) % ids.length];
|
|
276
|
+
else if (matchesKey(data, "enter") && this.choosing !== undefined)
|
|
277
|
+
this.open(job, this.choosing);
|
|
278
|
+
else
|
|
279
|
+
this.scroll(data);
|
|
280
|
+
}
|
|
281
|
+
sessionInput(data, job) {
|
|
282
|
+
if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
|
|
283
|
+
// Return to selection on the same node, so another node is one keypress away.
|
|
284
|
+
const focus = this.focus;
|
|
285
|
+
let nodeId;
|
|
286
|
+
try {
|
|
287
|
+
nodeId = job && ("nodeId" in focus ? focus.nodeId : this.jobs.getNode(job.jobId, undefined, focus.executionId).id);
|
|
288
|
+
}
|
|
289
|
+
catch { /* The node was removed; fall back to the plain graph. */ }
|
|
177
290
|
this.focus = undefined;
|
|
291
|
+
this.choosing = nodeId !== undefined && job && this.nodeIds(job).includes(nodeId) ? nodeId : undefined;
|
|
178
292
|
this.offset = 0;
|
|
179
293
|
}
|
|
180
|
-
else if (matchesKey(data, "
|
|
294
|
+
else if ((matchesKey(data, "left") || matchesKey(data, "right")) && job) {
|
|
295
|
+
let node;
|
|
296
|
+
try {
|
|
297
|
+
node = "nodeId" in this.focus ? this.jobs.getNode(job.jobId, this.focus.nodeId) : this.jobs.getNode(job.jobId, undefined, this.focus.executionId);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const siblings = this.executionsOf(job, node.id);
|
|
303
|
+
const at = node.executionId ? siblings.indexOf(node.executionId) : -1;
|
|
304
|
+
const next = siblings[at + (matchesKey(data, "right") ? 1 : -1)];
|
|
305
|
+
if (next) {
|
|
306
|
+
this.focus = { executionId: next };
|
|
307
|
+
this.follow = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else if (matchesKey(data, "end"))
|
|
311
|
+
this.follow = true;
|
|
312
|
+
else if (matchesKey(data, "ctrl+o"))
|
|
313
|
+
this.session.toggleExpanded();
|
|
314
|
+
else
|
|
315
|
+
this.scroll(data);
|
|
316
|
+
}
|
|
317
|
+
scroll(data) {
|
|
318
|
+
const before = this.offset;
|
|
319
|
+
if (matchesKey(data, "up"))
|
|
181
320
|
this.offset = Math.max(0, this.offset - 1);
|
|
182
321
|
else if (matchesKey(data, "down"))
|
|
183
322
|
this.offset = Math.min(this.maxOffset, this.offset + 1);
|
|
@@ -185,9 +324,19 @@ export class BraidPanel {
|
|
|
185
324
|
this.offset = Math.max(0, this.offset - 10);
|
|
186
325
|
else if (matchesKey(data, "pageDown"))
|
|
187
326
|
this.offset = Math.min(this.maxOffset, this.offset + 10);
|
|
188
|
-
else if (data
|
|
189
|
-
this.
|
|
190
|
-
|
|
327
|
+
else if (matchesKey(data, "home"))
|
|
328
|
+
this.offset = 0;
|
|
329
|
+
else
|
|
330
|
+
return;
|
|
331
|
+
// Scrolling up pauses following; reaching the bottom resumes it.
|
|
332
|
+
if (this.offset < before)
|
|
333
|
+
this.follow = false;
|
|
334
|
+
else if (this.offset >= this.maxOffset)
|
|
335
|
+
this.follow = true;
|
|
336
|
+
}
|
|
337
|
+
close() {
|
|
338
|
+
this.dispose();
|
|
339
|
+
this.done();
|
|
191
340
|
}
|
|
192
341
|
invalidate() { }
|
|
193
342
|
dispose() {
|
package/dist/display.js
CHANGED
|
@@ -1,26 +1,46 @@
|
|
|
1
1
|
import { render as renderMermaid } from "grok-mermaid";
|
|
2
|
-
import { Text, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Text, sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatActivityStatus, } from "@chrok/braid";
|
|
3
4
|
export const MAX_VISIBLE_EVENTS = 80;
|
|
4
5
|
export const MAX_VISIBLE_NODES = 80;
|
|
5
6
|
class FixedLines {
|
|
6
7
|
lines;
|
|
7
8
|
chartStart;
|
|
8
9
|
chartLength;
|
|
9
|
-
|
|
10
|
+
fallback;
|
|
11
|
+
focusMarks;
|
|
12
|
+
/**
|
|
13
|
+
* A too-wide chart is cropped to the terminal width around the first
|
|
14
|
+
* `focusMarks` entry drawn in it (the selected node, then a running node),
|
|
15
|
+
* or from its left edge. `fallback` (a node list while selecting) appears when
|
|
16
|
+
* there is no chart, or when the selected node cannot be found in it.
|
|
17
|
+
*/
|
|
18
|
+
constructor(lines, chartStart = lines.length, chartLength = 0, fallback = [], focusMarks = []) {
|
|
10
19
|
this.lines = lines;
|
|
11
20
|
this.chartStart = chartStart;
|
|
12
21
|
this.chartLength = chartLength;
|
|
22
|
+
this.fallback = fallback;
|
|
23
|
+
this.focusMarks = focusMarks;
|
|
13
24
|
}
|
|
14
25
|
render(width) {
|
|
15
26
|
const chart = this.lines.slice(this.chartStart, this.chartStart + this.chartLength);
|
|
16
|
-
|
|
27
|
+
const fallback = this.fallback.map((line) => truncateToWidth(line, width, ""));
|
|
28
|
+
if (chart.length === 0 && fallback.length === 0)
|
|
17
29
|
return this.lines.map((line) => truncateToWidth(line, width, ""));
|
|
18
|
-
const chartWidth = Math.max(...chart.map((line) => visibleWidth(line)));
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
const chartWidth = chart.length ? Math.max(...chart.map((line) => visibleWidth(line))) : 0;
|
|
31
|
+
let chartOutput = chart;
|
|
32
|
+
if (!chart.length)
|
|
33
|
+
chartOutput = fallback;
|
|
34
|
+
else if (chartWidth > width) {
|
|
35
|
+
const focus = this.focus(chart);
|
|
36
|
+
const start = focus === undefined ? 0 : Math.max(0, Math.min(chartWidth - width, focus.column - Math.floor(width / 2)));
|
|
37
|
+
chartOutput = [
|
|
38
|
+
truncateToWidth(`[chart ${start + 1}–${start + width} of ${chartWidth} cols${focus ? ` · ${focus.label}` : ""}]`, width, ""),
|
|
39
|
+
...chart.map((line) => sliceByColumn(line, start, width, true)),
|
|
40
|
+
// The first mark is the selection; list nodes if it is not drawn.
|
|
41
|
+
...(this.focusMarks.length && focus?.mark !== this.focusMarks[0].mark ? fallback : []),
|
|
23
42
|
];
|
|
43
|
+
}
|
|
24
44
|
const output = [];
|
|
25
45
|
let chartInserted = false;
|
|
26
46
|
for (let index = 0; index < this.lines.length; index++) {
|
|
@@ -37,6 +57,22 @@ class FixedLines {
|
|
|
37
57
|
output.push(...chartOutput);
|
|
38
58
|
return output;
|
|
39
59
|
}
|
|
60
|
+
/** The first focus mark drawn in the chart and its centre column. */
|
|
61
|
+
focus(chart) {
|
|
62
|
+
for (const { mark, label } of this.focusMarks) {
|
|
63
|
+
for (const line of chart) {
|
|
64
|
+
const plain = stripTerminalSequences(line);
|
|
65
|
+
const index = plain.indexOf(mark);
|
|
66
|
+
if (index < 0)
|
|
67
|
+
continue;
|
|
68
|
+
// Centre on the label: from the mark to the node's right border, when drawn.
|
|
69
|
+
const border = plain.indexOf("│", index);
|
|
70
|
+
const end = border < 0 ? index : border;
|
|
71
|
+
return { mark, label, column: Math.floor((visibleWidth(plain.slice(0, index)) + visibleWidth(plain.slice(0, end))) / 2) };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
40
76
|
invalidate() { }
|
|
41
77
|
}
|
|
42
78
|
function record(value) {
|
|
@@ -121,7 +157,9 @@ function nodeElapsed(node, now) {
|
|
|
121
157
|
}
|
|
122
158
|
return "—";
|
|
123
159
|
}
|
|
124
|
-
|
|
160
|
+
/** Replaces the selected node's one-column status icon, so selection never moves the layout. */
|
|
161
|
+
export const SELECTED_NODE_MARK = "◆";
|
|
162
|
+
function mermaidLabelLines(id, result, selected = false) {
|
|
125
163
|
const progress = "progress" in result ? result.progress?.[id] : undefined;
|
|
126
164
|
const node = result.nodes[id];
|
|
127
165
|
if (!node)
|
|
@@ -140,7 +178,7 @@ function mermaidLabelLines(id, result) {
|
|
|
140
178
|
const now = "observedAt" in result ? result.observedAt : Date.now();
|
|
141
179
|
const iteration = "executions" in result ? result.executions[node.executionId ?? ""]?.iteration : result.iterations?.[id];
|
|
142
180
|
const lines = [
|
|
143
|
-
`${icon} ${compact(id, 24)}${iteration ? ` #${iteration}` : ""}`,
|
|
181
|
+
`${selected ? SELECTED_NODE_MARK + icon.slice(1) : icon} ${compact(id, 24)}${iteration ? ` #${iteration}` : ""}`,
|
|
144
182
|
`${status}${decision} · ${nodeElapsed(node, now)}`,
|
|
145
183
|
];
|
|
146
184
|
if (progress) {
|
|
@@ -152,7 +190,7 @@ function mermaidLabelLines(id, result) {
|
|
|
152
190
|
}
|
|
153
191
|
return lines;
|
|
154
192
|
}
|
|
155
|
-
function mermaidSource(result) {
|
|
193
|
+
function mermaidSource(result, selected) {
|
|
156
194
|
const parts = graphParts(result);
|
|
157
195
|
const ids = Object.keys(parts.nodes);
|
|
158
196
|
const names = new Map(ids.map((id, index) => [id, `n${index}`]));
|
|
@@ -173,15 +211,22 @@ function mermaidSource(result) {
|
|
|
173
211
|
for (const id of ids) {
|
|
174
212
|
const shape = parts.nodeTypes[id] === "decision" ? "{" : "[";
|
|
175
213
|
const close = parts.nodeTypes[id] === "decision" ? "}" : "]";
|
|
176
|
-
lines.push(` ${names.get(id)}${shape}"${mermaidLabelLines(id, result).map(escapeMermaidText).join("<br/>")}"${close}`);
|
|
214
|
+
lines.push(` ${names.get(id)}${shape}"${mermaidLabelLines(id, result, id === selected).map(escapeMermaidText).join("<br/>")}"${close}`);
|
|
177
215
|
}
|
|
178
216
|
for (const edge of parts.edges) {
|
|
179
217
|
lines.push(` ${names.get(edge.from)} -->${edge.choice ? `|${escapeMermaidText(edge.choice)}|` : ""} ${names.get(edge.to)}`);
|
|
180
218
|
}
|
|
181
219
|
return lines.join("\n");
|
|
182
220
|
}
|
|
183
|
-
function mermaidLines(result, theme) {
|
|
184
|
-
|
|
221
|
+
function mermaidLines(result, theme, selected) {
|
|
222
|
+
let art;
|
|
223
|
+
try {
|
|
224
|
+
art = renderMermaid(mermaidSource(result, selected));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// The chart is a view of state already listed elsewhere; never fail the panel for it.
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
185
230
|
if (!art)
|
|
186
231
|
return [];
|
|
187
232
|
return art.styled.map((row) => row
|
|
@@ -190,6 +235,8 @@ function mermaidLines(result, theme) {
|
|
|
190
235
|
case "border":
|
|
191
236
|
return theme.fg("borderMuted", span.text);
|
|
192
237
|
case "text":
|
|
238
|
+
if (selected !== undefined && span.text.includes(SELECTED_NODE_MARK))
|
|
239
|
+
return theme.bg("selectedBg", theme.bold(theme.fg("warning", span.text)));
|
|
193
240
|
return span.text.includes("▶ ACTIVE")
|
|
194
241
|
? theme.bg("selectedBg", theme.bold(theme.fg("accent", span.text)))
|
|
195
242
|
: theme.fg("text", span.text);
|
|
@@ -428,8 +475,46 @@ export function applyProgress(state, progress) {
|
|
|
428
475
|
writable: true,
|
|
429
476
|
});
|
|
430
477
|
}
|
|
478
|
+
export const COLLAPSED_ACTIVITY_ENTRIES = 6;
|
|
479
|
+
/** Live phase, waits, and chronological history; absence of activity is shown, not judged. */
|
|
480
|
+
export function renderActivity(activity, expanded, theme, now, part = "all") {
|
|
481
|
+
const ago = (at) => elapsed(Math.max(0, now - at));
|
|
482
|
+
if (part === "history")
|
|
483
|
+
return renderActivity(activity, expanded, theme, now).slice(renderActivity(activity, expanded, theme, now, "summary").length);
|
|
484
|
+
const lines = [theme.fg(activity.finishedAt === undefined ? "accent" : "muted", `activity: ${compact(formatActivityStatus(activity, now), 200)}`)];
|
|
485
|
+
const model = activity.model;
|
|
486
|
+
if (model && model.completedAt === undefined) {
|
|
487
|
+
lines.push(theme.fg("muted", model.firstStreamAt === undefined
|
|
488
|
+
? ` model request #${model.round} sent ${ago(model.startedAt)} ago · no stream events received yet`
|
|
489
|
+
: ` model request #${model.round} sent ${ago(model.startedAt)} ago · ${model.streamEvents} stream events, ${compactCount(model.receivedChars)} chars · last ${ago(model.lastStreamAt)} ago`));
|
|
490
|
+
if (model.tail)
|
|
491
|
+
lines.push(theme.fg("dim", ` receiving ${model.receiving === "tool_call" ? `${compact(model.toolName ?? "tool call", 40)} arguments` : model.receiving ?? "output"}: …${compact(model.tail, 160)}`));
|
|
492
|
+
}
|
|
493
|
+
for (const tool of activity.tools) {
|
|
494
|
+
lines.push(theme.fg("warning", ` tool ${compact(tool.name, 40)} running ${ago(tool.startedAt)} · ${tool.lastOutputAt === undefined ? "no output yet" : `last output ${ago(tool.lastOutputAt)} ago`}`));
|
|
495
|
+
lines.push(theme.fg("dim", ` args: ${compact(tool.arguments, 200)}`));
|
|
496
|
+
if (tool.outputTail)
|
|
497
|
+
lines.push(theme.fg("dim", ` output: …${compact(tool.outputTail, 200)}`));
|
|
498
|
+
}
|
|
499
|
+
for (const limitation of activity.limitations)
|
|
500
|
+
lines.push(theme.fg("muted", ` unavailable: ${compact(limitation, 200)}`));
|
|
501
|
+
if (part === "summary")
|
|
502
|
+
return lines;
|
|
503
|
+
const visible = expanded ? activity.entries : activity.entries.slice(-COLLAPSED_ACTIVITY_ENTRIES);
|
|
504
|
+
const hidden = activity.droppedEntries + activity.entries.length - visible.length;
|
|
505
|
+
lines.push(theme.fg("dim", `history · ${activity.sequence} events${hidden ? ` · ${hidden} earlier ${expanded ? "trimmed" : "hidden (expand for more)"}` : ""}:`));
|
|
506
|
+
for (const entry of visible) {
|
|
507
|
+
const color = entry.isError ? "error" : entry.kind === "tool_call" ? "accent" : entry.kind === "assistant" ? "text" : "muted";
|
|
508
|
+
lines.push(theme.fg(color, ` ${`+${elapsed(Math.max(0, entry.timestamp - activity.startedAt))}`.padStart(7)} ${compact(entry.summary, 200)}`));
|
|
509
|
+
// Show a detail preview only when the summary line does not already contain it.
|
|
510
|
+
if (expanded && entry.detail && entry.kind !== "workspace" && !compact(entry.summary, 400).includes(compact(entry.detail, 400))) {
|
|
511
|
+
lines.push(theme.fg("dim", ` ${compact(entry.detail, 300)}${entry.detailLength ? ` (${compactCount(entry.detailLength)} chars)` : ""}`));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return lines;
|
|
515
|
+
}
|
|
431
516
|
/** Focused status reads must show the requested execution, including historical ones. */
|
|
432
|
-
export function renderNodeResult(node, expanded, theme, fullOutputPath, progress, now = Date.now()) {
|
|
517
|
+
export function renderNodeResult(node, expanded, theme, fullOutputPath, progress, now = Date.now(), activity) {
|
|
433
518
|
const failed = node.status === "failed";
|
|
434
519
|
const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
|
|
435
520
|
const timing = [
|
|
@@ -453,13 +538,48 @@ export function renderNodeResult(node, expanded, theme, fullOutputPath, progress
|
|
|
453
538
|
...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
|
|
454
539
|
...(node.error ? [theme.fg("error", `${node.error.code}: ${clean(node.error.message)}`)] : []),
|
|
455
540
|
...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []),
|
|
541
|
+
// Activity describes one execution; never mix histories from repeated visits.
|
|
542
|
+
...(activity && activity.executionId === node.executionId ? renderActivity(activity, expanded, theme, now) : []),
|
|
456
543
|
...(node.output ? [expanded ? clean(node.output) : compact(node.output, 400)] : []),
|
|
457
544
|
...(!expanded && node.output && (node.output.length > 400 || node.output.includes("\n")) ? [theme.fg("dim", "Expand for full node output")] : []),
|
|
458
545
|
...(fullOutputPath ? [theme.fg("dim", `full node result: ${compact(fullOutputPath, 240)}`)] : []),
|
|
459
546
|
];
|
|
460
547
|
return new Text(lines.join("\n"), 0, 0);
|
|
461
548
|
}
|
|
462
|
-
|
|
549
|
+
/** Status, timing, and live waits above a node's session transcript. */
|
|
550
|
+
export function renderSessionHeader(node, theme, now, progress, activity) {
|
|
551
|
+
const failed = node.status === "failed";
|
|
552
|
+
const live = progress && (!progress.executionId || progress.executionId === node.executionId) ? progress : undefined;
|
|
553
|
+
const facts = [
|
|
554
|
+
...(node.executionId ? [compact(node.executionId, 80)] : []),
|
|
555
|
+
...(node.iteration ? [`iteration #${node.iteration}`] : []),
|
|
556
|
+
...(node.model ? [compact(node.model, 60)] : []),
|
|
557
|
+
...(node.startedAt !== undefined || node.latencyMs !== undefined ? [`elapsed ${nodeElapsed(node, now)}`] : []),
|
|
558
|
+
...(node.usage ? [`${compactCount(node.usage.inputTokens)} in / ${compactCount(node.usage.outputTokens)} out tokens`] : []),
|
|
559
|
+
];
|
|
560
|
+
return [
|
|
561
|
+
theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : node.status === "running" ? "▶" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`),
|
|
562
|
+
...(facts.length ? [theme.fg("muted", facts.join(" · "))] : []),
|
|
563
|
+
...(live ? [theme.fg("muted", `context ${live.contextSource === "estimate" ? "~" : ""}${compactCount(live.contextTokens)}/${live.contextWindow === undefined ? "—" : compactCount(live.contextWindow)} · ${live.toolCalls} tool calls in ${live.toolRounds} rounds · ${live.phase} phase`)] : []),
|
|
564
|
+
...(activity && activity.executionId === node.executionId ? renderActivity(activity, false, theme, now, "summary") : []),
|
|
565
|
+
...(node.error ? [theme.fg("error", `${node.error.code}: ${compact(node.error.message, 300)}`)] : []),
|
|
566
|
+
];
|
|
567
|
+
}
|
|
568
|
+
/** What a node view shows below its pinned header when no live session is retained. */
|
|
569
|
+
export function renderNodeDetails(node, theme, now, activity) {
|
|
570
|
+
const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
|
|
571
|
+
const workspace = node.workspace;
|
|
572
|
+
const workspaceActive = workspace && ["preparing", "ready", "failed"].includes(workspace.state);
|
|
573
|
+
const workspaceRef = workspaceActive ? workspace.worktreeRoot : workspace?.checkpointRef ?? workspace?.reason;
|
|
574
|
+
return [
|
|
575
|
+
...(workspace ? [theme.fg("dim", `workspace: ${workspace.mode} · ${workspace.state}${workspaceRef ? ` · ${compact(workspaceRef, 160)}` : ""}`)] : []),
|
|
576
|
+
...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
|
|
577
|
+
...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []),
|
|
578
|
+
...(activity && activity.executionId === node.executionId ? renderActivity(activity, true, theme, now, "history") : []),
|
|
579
|
+
...(node.output ? ["", ...clean(node.output).split("\n")] : []),
|
|
580
|
+
];
|
|
581
|
+
}
|
|
582
|
+
export function renderGraphResult(result, expanded, isPartial, theme, fallback = "", isError = false, selectedNodeId) {
|
|
463
583
|
if (isError || !result?.nodes) {
|
|
464
584
|
const message = fallback ||
|
|
465
585
|
(isPartial ? "Braid is running…" : "Braid returned no execution details");
|
|
@@ -495,8 +615,17 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
|
|
|
495
615
|
if ("executions" in result)
|
|
496
616
|
lines.push(theme.fg("dim", `${Object.keys(result.executions).length} executions · revision ${result.revision}`));
|
|
497
617
|
const chartStart = lines.length;
|
|
498
|
-
const chart = mermaidLines(result, theme);
|
|
618
|
+
const chart = mermaidLines(result, theme, selectedNodeId);
|
|
499
619
|
lines.push(...chart);
|
|
620
|
+
// Selection must stay usable when the chart cannot be drawn or does not fit.
|
|
621
|
+
const nodeList = selectedNodeId === undefined ? [] : [
|
|
622
|
+
theme.fg("dim", chart.length ? "nodes (selected node not found in the flowchart):" : "nodes (flowchart unavailable):"),
|
|
623
|
+
...Object.keys(graphParts(result).nodes).map(id => {
|
|
624
|
+
const node = result.nodes[id];
|
|
625
|
+
const text = `${id === selectedNodeId ? `${SELECTED_NODE_MARK} ` : " "}${compact(id, 60)} · ${node?.status ?? "pending"}`;
|
|
626
|
+
return id === selectedNodeId ? theme.bg("selectedBg", theme.bold(theme.fg("warning", text))) : theme.fg("muted", text);
|
|
627
|
+
}),
|
|
628
|
+
];
|
|
500
629
|
const workspaces = Object.values(result.workspaces ?? {}).filter(workspace => workspace.worktreeRoot);
|
|
501
630
|
if (workspaces.length) {
|
|
502
631
|
const active = workspaces.filter(workspace => ["preparing", "ready", "failed"].includes(workspace.state)).length;
|
|
@@ -512,5 +641,8 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
|
|
|
512
641
|
lines.push(...renderEventLog(result.events ?? [], true, theme));
|
|
513
642
|
if ("fullOutputPath" in result && result.fullOutputPath)
|
|
514
643
|
lines.push(theme.fg("dim", `full result/log: ${compact(result.fullOutputPath, 240)}`));
|
|
515
|
-
return new FixedLines(lines, chartStart, chart.length
|
|
644
|
+
return new FixedLines(lines, chartStart, chart.length, nodeList, [
|
|
645
|
+
...(selectedNodeId === undefined ? [] : [{ mark: SELECTED_NODE_MARK, label: "selected node" }]),
|
|
646
|
+
{ mark: "▶ ACTIVE", label: "running node" },
|
|
647
|
+
]);
|
|
516
648
|
}
|
package/dist/index.js
CHANGED
|
@@ -108,6 +108,8 @@ function isJobSnapshot(value) {
|
|
|
108
108
|
["running", "completed", "failed", "cancelled"].includes(job.status ?? "") &&
|
|
109
109
|
!!job.live && typeof job.live.nodes === "object" && job.live.nodes !== null;
|
|
110
110
|
}
|
|
111
|
+
/** Focused reads return the latest activity entries; activityBefore pages earlier ones. */
|
|
112
|
+
export const STATUS_ACTIVITY_ENTRIES = 40;
|
|
111
113
|
function executionControl(job) {
|
|
112
114
|
return job.execution && {
|
|
113
115
|
status: job.execution.status,
|
|
@@ -202,11 +204,12 @@ export function createBraidTools(jobs) {
|
|
|
202
204
|
jobId: Type.Optional(text("Exact session handle (e.g. job-1) or UUID; omit all IDs to list jobs.")),
|
|
203
205
|
executionId: Type.Optional(text("Exact execution ID from braid_status or a reminder; requires jobId. Takes precedence over nodeId; if both are provided they must match.")),
|
|
204
206
|
nodeId: Type.Optional(text("Exact node ID; requires jobId. Retrieve this node's latest full output/error even while the job is running.")),
|
|
207
|
+
activityBefore: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: `Focused reads include activity: the current phase, model/tool waits, and the latest ${STATUS_ACTIVITY_ENTRIES} history entries. Pass an entry sequence to page earlier entries.` })),
|
|
205
208
|
}, { additionalProperties: false });
|
|
206
209
|
const statusTool = defineTool({
|
|
207
210
|
name: "braid_status",
|
|
208
211
|
label: "Braid status",
|
|
209
|
-
description: "Retrieve a background Braid job's status, node progress, and final results by exact session handle (e.g. job-1) or UUID in jobId. Add executionId for an exact execution, or nodeId for the latest instance, including intermediate results while the job runs. All job reads include current execution.revision and execution.pausedExecutionIds for live control; node.revision is the historical invocation revision. Large results include a path to the full JSON, including while running. For usage totals use usage (Pi provider accounting, including rounds from failed nodes), or piUsage in the saved final result. Prefer the short handle from submission/reminders. Omit all IDs to list jobs in this session. Completion reminders arrive automatically; avoid repeated polling.",
|
|
212
|
+
description: "Retrieve a background Braid job's status, node progress, and final results by exact session handle (e.g. job-1) or UUID in jobId. Add executionId for an exact execution, or nodeId for the latest instance, including intermediate results while the job runs. Focused reads include live activity: phase, time in phase and since the last observed signal, model request/stream state, running tools, and recent history. Lack of observed activity is a diagnostic signal, not proof of a stall. All job reads include current execution.revision and execution.pausedExecutionIds for live control; node.revision is the historical invocation revision. Large results include a path to the full JSON, including while running. For usage totals use usage (Pi provider accounting, including rounds from failed nodes), or piUsage in the saved final result. Prefer the short handle from submission/reminders. Omit all IDs to list jobs in this session. Completion reminders arrive automatically; avoid repeated polling.",
|
|
210
213
|
parameters: statusParameters,
|
|
211
214
|
renderResult(result, options, theme, ctx) {
|
|
212
215
|
const job = result.details;
|
|
@@ -227,7 +230,7 @@ export function createBraidTools(jobs) {
|
|
|
227
230
|
: nodeId && state && Object.hasOwn(state.nodes, nodeId) ? state.nodes[nodeId] : undefined);
|
|
228
231
|
// Render at the read's observation time so a saved result never keeps counting.
|
|
229
232
|
if (selectedNode)
|
|
230
|
-
return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath, job.live.progress?.[selectedNode.id], job.live.observedAt);
|
|
233
|
+
return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath, job.live.progress?.[selectedNode.id], job.live.observedAt, job.selectedActivity);
|
|
231
234
|
if (executionId || nodeId)
|
|
232
235
|
return new Text(fallback || "Braid returned no node details", 0, 0);
|
|
233
236
|
if (job.error)
|
|
@@ -242,6 +245,8 @@ export function createBraidTools(jobs) {
|
|
|
242
245
|
async execute(_id, params) {
|
|
243
246
|
if ((params.nodeId !== undefined || params.executionId !== undefined) && !params.jobId)
|
|
244
247
|
throw new Error("nodeId/executionId requires jobId");
|
|
248
|
+
if (params.activityBefore !== undefined && params.nodeId === undefined && params.executionId === undefined)
|
|
249
|
+
throw new Error("activityBefore requires nodeId or executionId");
|
|
245
250
|
if (!params.jobId)
|
|
246
251
|
return {
|
|
247
252
|
content: [{ type: "text", text: JSON.stringify(jobs.list()) }],
|
|
@@ -253,9 +258,18 @@ export function createBraidTools(jobs) {
|
|
|
253
258
|
if (params.nodeId !== undefined || params.executionId !== undefined) {
|
|
254
259
|
const node = jobs.getNode(params.jobId, params.nodeId, params.executionId);
|
|
255
260
|
const { id, executionId, status, error, output, ...nodeDetails } = node;
|
|
261
|
+
const activity = executionId ? jobs.getActivity(params.jobId, executionId, {
|
|
262
|
+
limit: STATUS_ACTIVITY_ENTRIES, ...(params.activityBefore === undefined ? {} : { before: params.activityBefore }),
|
|
263
|
+
}) : undefined;
|
|
264
|
+
// While running, activity is the useful part; afterwards the output is,
|
|
265
|
+
// so it comes first and history cannot push it out of the preview.
|
|
266
|
+
const running = activity && activity.finishedAt === undefined;
|
|
267
|
+
const live = activity ? { observedAt: job.live.observedAt, activity } : {};
|
|
256
268
|
const full = JSON.stringify({
|
|
257
269
|
jobId: job.jobId, handle: job.handle, status: job.status, execution: executionControl(job),
|
|
270
|
+
...(running ? live : {}),
|
|
258
271
|
node: { id, executionId, status, error, output, ...nodeDetails },
|
|
272
|
+
...(running ? {} : live),
|
|
259
273
|
}, null, 2);
|
|
260
274
|
const preview = truncateHead(full);
|
|
261
275
|
let suffix = "";
|
|
@@ -269,7 +283,7 @@ export function createBraidTools(jobs) {
|
|
|
269
283
|
}
|
|
270
284
|
// Focused reads do not claim the whole job's usage; final job retrieval does.
|
|
271
285
|
return { content: [{ type: "text", text: preview.content + suffix }], details: {
|
|
272
|
-
...job, selectedNode: node, ...(nodeOutputPath ? { nodeOutputPath } : {}),
|
|
286
|
+
...job, selectedNode: node, ...(activity ? { selectedActivity: activity } : {}), ...(nodeOutputPath ? { nodeOutputPath } : {}),
|
|
273
287
|
} };
|
|
274
288
|
}
|
|
275
289
|
const { jobId, handle, status, error, usage: providerUsage, fullOutputPath, execution, ...snapshot } = job;
|
package/dist/jobs.js
CHANGED
|
@@ -3,9 +3,10 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { setImmediate as nextTurn } from "node:timers/promises";
|
|
5
5
|
import { truncateHead, } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { startBraid, validateGraph, } from "@chrok/braid";
|
|
6
|
+
import { ExecutionActivityTracker, startBraid, validateGraph, } from "@chrok/braid";
|
|
7
7
|
import { applyEvent, applyProgress, createLiveState, } from "./display.js";
|
|
8
8
|
import { createPiRunner, sumPiUsage } from "./runner.js";
|
|
9
|
+
import { PiTranscripts } from "./transcript.js";
|
|
9
10
|
/** Jobs belong to one extension/session lifetime, independently of foreground turns. */
|
|
10
11
|
export class BraidJobs {
|
|
11
12
|
onFinished;
|
|
@@ -57,6 +58,8 @@ export class BraidJobs {
|
|
|
57
58
|
done: Promise.resolve(),
|
|
58
59
|
usageClaimed: false,
|
|
59
60
|
runningExecutions: new Map(),
|
|
61
|
+
activity: new ExecutionActivityTracker({ onChange: () => this.changed() }),
|
|
62
|
+
transcripts: new PiTranscripts(() => this.changed()),
|
|
60
63
|
nodeResults: new Map(snapshot.nodes.map((node) => [node.id, { id: node.id, status: "pending" }])),
|
|
61
64
|
};
|
|
62
65
|
this.jobs.set(job.jobId, job);
|
|
@@ -79,6 +82,8 @@ export class BraidJobs {
|
|
|
79
82
|
runner: (() => {
|
|
80
83
|
const invoke = createPiRunner(registry, {
|
|
81
84
|
cwd,
|
|
85
|
+
activity: job.activity,
|
|
86
|
+
transcripts: job.transcripts,
|
|
82
87
|
maxToolRounds: options.maxToolRounds ?? Infinity,
|
|
83
88
|
maxToolCalls: options.maxToolCalls ?? Infinity,
|
|
84
89
|
onUsage: (usage) => {
|
|
@@ -92,9 +97,19 @@ export class BraidJobs {
|
|
|
92
97
|
this.changed();
|
|
93
98
|
},
|
|
94
99
|
});
|
|
95
|
-
return async (request) => {
|
|
100
|
+
return async (request) => {
|
|
101
|
+
try {
|
|
102
|
+
await nextTurn();
|
|
103
|
+
return await invoke(request);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
job.activity.workerFinished(request.execution.executionId);
|
|
107
|
+
job.transcripts.finish(request.execution.executionId, request.signal.aborted ? "Execution was stopped before this step finished" : undefined);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
96
110
|
})(),
|
|
97
111
|
onEvent: (event) => {
|
|
112
|
+
job.activity.observe(event);
|
|
98
113
|
if (event.type === "workspace_updated") {
|
|
99
114
|
job.workspaces ??= {};
|
|
100
115
|
Object.defineProperty(job.workspaces, event.workspace.executionId ?? event.workspace.nodeId, {
|
|
@@ -198,7 +213,7 @@ export class BraidJobs {
|
|
|
198
213
|
const job = this.lookup(jobId);
|
|
199
214
|
if (!job)
|
|
200
215
|
return undefined;
|
|
201
|
-
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, ...snapshot } = job;
|
|
216
|
+
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, ...snapshot } = job;
|
|
202
217
|
const copy = structuredClone(snapshot);
|
|
203
218
|
if (job.run)
|
|
204
219
|
copy.execution = job.run.snapshot();
|
|
@@ -220,6 +235,20 @@ export class BraidJobs {
|
|
|
220
235
|
throw new Error(`Unknown Braid node or execution in ${job.handle}. Use braid_status with only jobId to list executions.`);
|
|
221
236
|
return structuredClone(node);
|
|
222
237
|
}
|
|
238
|
+
/** Live activity for one exact execution; undefined until it is first observed. */
|
|
239
|
+
getActivity(jobId, executionId, page) {
|
|
240
|
+
const job = this.lookup(jobId);
|
|
241
|
+
if (!job)
|
|
242
|
+
throw this.unknownJob(jobId);
|
|
243
|
+
return job.activity.get(executionId, page);
|
|
244
|
+
}
|
|
245
|
+
/** The live conversation for one execution; read-only, and absent once evicted. */
|
|
246
|
+
getTranscript(jobId, executionId) {
|
|
247
|
+
const job = this.lookup(jobId);
|
|
248
|
+
if (!job)
|
|
249
|
+
throw this.unknownJob(jobId);
|
|
250
|
+
return job.transcripts.get(executionId);
|
|
251
|
+
}
|
|
223
252
|
update(jobId, patch) {
|
|
224
253
|
const job = this.lookup(jobId);
|
|
225
254
|
if (!job)
|
package/dist/runner.js
CHANGED
|
@@ -41,6 +41,9 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
41
41
|
const model = registry.find(name.slice(0, slash), name.slice(slash + 1));
|
|
42
42
|
if (!model)
|
|
43
43
|
throw new Error(`Pi model '${name}' is not registered; use an exact provider/modelId from /model`);
|
|
44
|
+
const activity = options.activity?.recorder(request);
|
|
45
|
+
if (!registry.stream)
|
|
46
|
+
activity?.limitation("This Pi model registry does not stream; model activity is observed only when a request starts and finishes.");
|
|
44
47
|
const workspace = request.workspace ?? {
|
|
45
48
|
nodeId: request.node.id, mode: "read-only", workingDirectory: sourceDirectory, state: "ready",
|
|
46
49
|
};
|
|
@@ -139,6 +142,14 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
139
142
|
tools: allToolDefinitions,
|
|
140
143
|
};
|
|
141
144
|
const toolByName = new Map(fileTools.map((tool) => [tool.name, tool]));
|
|
145
|
+
const executionId = request.execution.executionId;
|
|
146
|
+
const transcripts = executionId === undefined ? undefined : options.transcripts;
|
|
147
|
+
transcripts?.start(executionId, request.node.id, workingDirectory, {
|
|
148
|
+
prompt: request.node.prompt ?? `Default ${request.node.type} instructions`,
|
|
149
|
+
goal: request.goal,
|
|
150
|
+
predecessors: request.predecessors.length,
|
|
151
|
+
workspace: `${workspace.mode} · ${workingDirectory}`,
|
|
152
|
+
}, toolByName);
|
|
142
153
|
const systemPrompt = context.systemPrompt;
|
|
143
154
|
const sessionId = crypto.randomUUID();
|
|
144
155
|
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
@@ -171,14 +182,54 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
171
182
|
toolRounds,
|
|
172
183
|
phase: "model",
|
|
173
184
|
});
|
|
174
|
-
//
|
|
175
|
-
|
|
185
|
+
// The registry resolves stored API keys, OAuth, custom headers and endpoints.
|
|
186
|
+
// complete() is stream().result(); streaming only adds observation.
|
|
187
|
+
const requestOptions = {
|
|
176
188
|
signal: request.signal,
|
|
177
189
|
sessionId,
|
|
178
190
|
cacheRetention: "none",
|
|
179
191
|
transport: "sse",
|
|
180
192
|
maxRetries: 0,
|
|
193
|
+
};
|
|
194
|
+
activity?.modelRequest();
|
|
195
|
+
let response;
|
|
196
|
+
try {
|
|
197
|
+
if (registry.stream) {
|
|
198
|
+
const stream = registry.stream(model, context, requestOptions);
|
|
199
|
+
for await (const event of stream) {
|
|
200
|
+
if (event.type !== "done" && event.type !== "error")
|
|
201
|
+
transcripts?.streaming(executionId, event.partial);
|
|
202
|
+
if (event.type === "text_delta")
|
|
203
|
+
activity?.modelStream("text", event.delta);
|
|
204
|
+
else if (event.type === "thinking_delta")
|
|
205
|
+
activity?.modelStream("reasoning", event.delta);
|
|
206
|
+
else if (event.type === "toolcall_start" || event.type === "toolcall_delta") {
|
|
207
|
+
const block = event.partial.content[event.contentIndex];
|
|
208
|
+
activity?.modelStream("tool_call", event.type === "toolcall_delta" ? event.delta : "", block?.type === "toolCall" ? block.name : undefined);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
response = await stream.result();
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
response = await registry.complete(model, context, requestOptions);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
activity?.modelError(error instanceof Error ? error.message : String(error));
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
transcripts?.assistant(executionId, response);
|
|
222
|
+
activity?.modelResponse({
|
|
223
|
+
text: response.content.flatMap(block => block.type === "text" ? [block.text] : []).join("\n\n"),
|
|
224
|
+
reasoning: response.content.flatMap(block => block.type === "thinking" ? [block.thinking] : []).join("\n\n"),
|
|
225
|
+
toolCalls: response.content.flatMap(block => block.type === "toolCall" ? [{ id: block.id, name: block.name }] : []),
|
|
226
|
+
stopReason: response.stopReason,
|
|
227
|
+
inputTokens: response.usage.input + response.usage.cacheRead + response.usage.cacheWrite,
|
|
228
|
+
outputTokens: response.usage.output,
|
|
181
229
|
});
|
|
230
|
+
if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
|
|
231
|
+
activity?.modelError(response.errorMessage || `Pi model ended with '${response.stopReason}'`);
|
|
232
|
+
}
|
|
182
233
|
actualModel = `${response.provider}/${response.responseModel ?? response.model}`;
|
|
183
234
|
const providerContextTokens = response.usage.input +
|
|
184
235
|
response.usage.cacheRead +
|
|
@@ -234,7 +285,13 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
234
285
|
return toolResult(call, "Merge dispositions recorded. Return your final answer.", false);
|
|
235
286
|
}
|
|
236
287
|
const args = validateToolCall(allToolDefinitions, call);
|
|
237
|
-
const execute = () => tool.execute(call.id, args, request.signal,
|
|
288
|
+
const execute = () => tool.execute(call.id, args, request.signal,
|
|
289
|
+
// Bash sends cumulative, throttled output snapshots.
|
|
290
|
+
(partial) => {
|
|
291
|
+
activity?.toolOutput(call.id, partial.content
|
|
292
|
+
.flatMap(block => block.type === "text" ? [block.text] : []).join(""), "replace");
|
|
293
|
+
transcripts?.toolUpdate(executionId, call.id, { content: partial.content, details: partial.details });
|
|
294
|
+
});
|
|
238
295
|
const result = ["write", "edit", "bash", "powershell"].includes(call.name) && request.withWorkspaceWrite
|
|
239
296
|
? await request.withWorkspaceWrite(execute)
|
|
240
297
|
: await execute();
|
|
@@ -254,6 +311,24 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
254
311
|
return toolResult(call, error instanceof Error ? error.message : String(error), true);
|
|
255
312
|
}
|
|
256
313
|
};
|
|
314
|
+
const executeCall = async (call) => {
|
|
315
|
+
if (call.name !== "decide")
|
|
316
|
+
return executeFileTool(call);
|
|
317
|
+
if (request.node.type !== "decision") {
|
|
318
|
+
return toolResult(call, "decide is available only on decision nodes", true);
|
|
319
|
+
}
|
|
320
|
+
const args = call.arguments;
|
|
321
|
+
if (!args ||
|
|
322
|
+
Array.isArray(args) ||
|
|
323
|
+
Object.keys(args).length !== 1 ||
|
|
324
|
+
typeof args.choice !== "string") {
|
|
325
|
+
throw new Error("decide requires exactly one string argument: choice");
|
|
326
|
+
}
|
|
327
|
+
// No coercion or prose parsing: the core enforces the enum and exactly one call.
|
|
328
|
+
request.decide(args.choice);
|
|
329
|
+
decided = true;
|
|
330
|
+
return toolResult(call, JSON.stringify({ choice: args.choice }), false);
|
|
331
|
+
};
|
|
257
332
|
while (true) {
|
|
258
333
|
const response = await complete();
|
|
259
334
|
textParts.push(...response.content
|
|
@@ -287,26 +362,20 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
287
362
|
const results = [];
|
|
288
363
|
for (const call of calls) {
|
|
289
364
|
request.signal.throwIfAborted();
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const args = call.arguments;
|
|
296
|
-
if (!args ||
|
|
297
|
-
Array.isArray(args) ||
|
|
298
|
-
Object.keys(args).length !== 1 ||
|
|
299
|
-
typeof args.choice !== "string") {
|
|
300
|
-
throw new Error("decide requires exactly one string argument: choice");
|
|
301
|
-
}
|
|
302
|
-
// No coercion or prose parsing: the core enforces the enum and exactly one call.
|
|
303
|
-
request.decide(args.choice);
|
|
304
|
-
decided = true;
|
|
305
|
-
results.push(toolResult(call, JSON.stringify({ choice: args.choice }), false));
|
|
365
|
+
activity?.toolStart(call.id, call.name, call.arguments);
|
|
366
|
+
transcripts?.toolStart(executionId, call.id);
|
|
367
|
+
let result;
|
|
368
|
+
try {
|
|
369
|
+
result = await executeCall(call);
|
|
306
370
|
}
|
|
307
|
-
|
|
308
|
-
|
|
371
|
+
catch (error) {
|
|
372
|
+
activity?.toolEnd(call.id, error instanceof Error ? error.message : String(error), true);
|
|
373
|
+
throw error;
|
|
309
374
|
}
|
|
375
|
+
activity?.toolEnd(call.id, result.content
|
|
376
|
+
.flatMap(block => block.type === "text" ? [block.text] : []).join("\n"), result.isError);
|
|
377
|
+
transcripts?.toolResult(executionId, result);
|
|
378
|
+
results.push(result);
|
|
310
379
|
}
|
|
311
380
|
context.messages.push(...results);
|
|
312
381
|
if (decided) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { AssistantMessageComponent, getMarkdownTheme, ToolExecutionComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, stripTerminalSequences, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
|
|
4
|
+
/**
|
|
5
|
+
* Renders a worker conversation with Pi's own chat components, applying only
|
|
6
|
+
* what changed since the last sync, the way Pi's interactive mode streams.
|
|
7
|
+
*/
|
|
8
|
+
export class NodeSessionView {
|
|
9
|
+
tui;
|
|
10
|
+
theme;
|
|
11
|
+
chat = new Container();
|
|
12
|
+
transcript;
|
|
13
|
+
version = -1;
|
|
14
|
+
rendered = 0;
|
|
15
|
+
streaming;
|
|
16
|
+
tools = new Map();
|
|
17
|
+
partials = new Map();
|
|
18
|
+
expanded = false;
|
|
19
|
+
constructor(tui, theme) {
|
|
20
|
+
this.tui = tui;
|
|
21
|
+
this.theme = theme;
|
|
22
|
+
}
|
|
23
|
+
/** Tool output expansion, like Pi's ctrl+o. */
|
|
24
|
+
toggleExpanded() {
|
|
25
|
+
this.expanded = !this.expanded;
|
|
26
|
+
for (const tool of this.tools.values())
|
|
27
|
+
tool.setExpanded(this.expanded);
|
|
28
|
+
this.version = -1;
|
|
29
|
+
}
|
|
30
|
+
render(transcript, width) {
|
|
31
|
+
this.sync(transcript);
|
|
32
|
+
// Pi marks its own prompts for terminal shell integration; these are not prompts.
|
|
33
|
+
return this.chat.render(width).map(line => line.replace(/\x1b\]133;[^\x07\x1b]*(?:\x07|\x1b\\)/g, ""));
|
|
34
|
+
}
|
|
35
|
+
sync(transcript) {
|
|
36
|
+
if (transcript !== this.transcript)
|
|
37
|
+
this.reset(transcript);
|
|
38
|
+
if (transcript.version === this.version)
|
|
39
|
+
return;
|
|
40
|
+
this.version = transcript.version;
|
|
41
|
+
for (; this.rendered < transcript.messages.length; this.rendered++) {
|
|
42
|
+
const message = transcript.messages[this.rendered];
|
|
43
|
+
if (message.role === "toolResult") {
|
|
44
|
+
this.tools.get(message.toolCallId)?.updateResult(message);
|
|
45
|
+
this.partials.delete(message.toolCallId);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// The final message replaces the streamed partial in the same component.
|
|
49
|
+
const component = this.streaming ?? this.add(new AssistantMessageComponent(undefined, false, getMarkdownTheme()));
|
|
50
|
+
this.streaming = undefined;
|
|
51
|
+
component.updateContent(message, false);
|
|
52
|
+
this.toolCalls(message, transcript, true);
|
|
53
|
+
}
|
|
54
|
+
if (transcript.streaming) {
|
|
55
|
+
this.streaming ??= this.add(new AssistantMessageComponent(undefined, false, getMarkdownTheme()));
|
|
56
|
+
this.streaming.updateContent(transcript.streaming, true);
|
|
57
|
+
this.toolCalls(transcript.streaming, transcript, false);
|
|
58
|
+
}
|
|
59
|
+
for (const [callId, partial] of transcript.running) {
|
|
60
|
+
const tool = this.tools.get(callId);
|
|
61
|
+
if (!tool || this.partials.get(callId) === partial)
|
|
62
|
+
continue;
|
|
63
|
+
this.partials.set(callId, partial);
|
|
64
|
+
tool.markExecutionStarted();
|
|
65
|
+
if (partial)
|
|
66
|
+
tool.updateResult({ ...partial, isError: false }, true);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
toolCalls(message, transcript, complete) {
|
|
70
|
+
for (const block of message.content) {
|
|
71
|
+
if (block.type !== "toolCall")
|
|
72
|
+
continue;
|
|
73
|
+
let tool = this.tools.get(block.id);
|
|
74
|
+
if (!tool) {
|
|
75
|
+
tool = new ToolExecutionComponent(block.name, block.id, block.arguments, undefined, transcript.renderers.get(block.name), this.tui, transcript.cwd);
|
|
76
|
+
tool.setExpanded(this.expanded);
|
|
77
|
+
this.tools.set(block.id, tool);
|
|
78
|
+
this.add(tool);
|
|
79
|
+
}
|
|
80
|
+
else if (!complete)
|
|
81
|
+
tool.updateArgs(block.arguments);
|
|
82
|
+
if (complete) {
|
|
83
|
+
if (message.stopReason === "aborted" || message.stopReason === "error") {
|
|
84
|
+
tool.updateResult({ content: [{ type: "text", text: message.errorMessage || "Error" }], isError: true });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
tool.updateArgs(block.arguments);
|
|
88
|
+
tool.setArgsComplete();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
reset(transcript) {
|
|
94
|
+
this.transcript = transcript;
|
|
95
|
+
this.chat.clear();
|
|
96
|
+
this.tools.clear();
|
|
97
|
+
this.partials.clear();
|
|
98
|
+
this.streaming = undefined;
|
|
99
|
+
this.rendered = 0;
|
|
100
|
+
this.version = -1;
|
|
101
|
+
const intro = transcript.intro;
|
|
102
|
+
this.add(new UserMessageComponent(intro.prompt, getMarkdownTheme()));
|
|
103
|
+
this.add(new Text(this.theme.fg("dim", [
|
|
104
|
+
`goal: ${plain(intro.goal)}`,
|
|
105
|
+
`context: ${intro.predecessors} predecessor output${intro.predecessors === 1 ? "" : "s"} · workspace ${plain(intro.workspace)}`,
|
|
106
|
+
].join("\n")), 1, 0));
|
|
107
|
+
}
|
|
108
|
+
add(component) {
|
|
109
|
+
this.chat.addChild(component);
|
|
110
|
+
return component;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live worker conversations keyed by execution ID. Running executions and the
|
|
3
|
+
* newest finished ones are retained; older transcripts are dropped to bound memory.
|
|
4
|
+
*/
|
|
5
|
+
export class PiTranscripts {
|
|
6
|
+
onChange;
|
|
7
|
+
maxFinished;
|
|
8
|
+
transcripts = new Map();
|
|
9
|
+
finishedIds = [];
|
|
10
|
+
pending;
|
|
11
|
+
constructor(onChange = () => { }, maxFinished = 20) {
|
|
12
|
+
this.onChange = onChange;
|
|
13
|
+
this.maxFinished = maxFinished;
|
|
14
|
+
}
|
|
15
|
+
get(executionId) {
|
|
16
|
+
return this.transcripts.get(executionId);
|
|
17
|
+
}
|
|
18
|
+
start(executionId, nodeId, cwd, intro, renderers) {
|
|
19
|
+
this.transcripts.set(executionId, {
|
|
20
|
+
executionId, nodeId, cwd, intro, messages: [], running: new Map(), renderers, version: 0, finished: false,
|
|
21
|
+
});
|
|
22
|
+
this.changed();
|
|
23
|
+
}
|
|
24
|
+
streaming(executionId, partial) {
|
|
25
|
+
this.update(executionId, transcript => { transcript.streaming = partial; }, true);
|
|
26
|
+
}
|
|
27
|
+
assistant(executionId, message) {
|
|
28
|
+
this.update(executionId, transcript => {
|
|
29
|
+
delete transcript.streaming;
|
|
30
|
+
transcript.messages.push(message);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
toolStart(executionId, callId) {
|
|
34
|
+
this.update(executionId, transcript => { transcript.running.set(callId, undefined); });
|
|
35
|
+
}
|
|
36
|
+
toolUpdate(executionId, callId, partial) {
|
|
37
|
+
this.update(executionId, transcript => {
|
|
38
|
+
if (transcript.running.has(callId))
|
|
39
|
+
transcript.running.set(callId, partial);
|
|
40
|
+
}, true);
|
|
41
|
+
}
|
|
42
|
+
toolResult(executionId, result) {
|
|
43
|
+
this.update(executionId, transcript => {
|
|
44
|
+
transcript.running.delete(result.toolCallId);
|
|
45
|
+
transcript.messages.push(result);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Close interrupted streams and tools so the view does not show them as still running. */
|
|
49
|
+
finish(executionId, reason = "Execution ended before this step finished") {
|
|
50
|
+
if (executionId === undefined)
|
|
51
|
+
return;
|
|
52
|
+
this.update(executionId, transcript => {
|
|
53
|
+
if (transcript.streaming) {
|
|
54
|
+
transcript.messages.push({ ...transcript.streaming, stopReason: "aborted", errorMessage: reason });
|
|
55
|
+
delete transcript.streaming;
|
|
56
|
+
}
|
|
57
|
+
for (const callId of transcript.running.keys()) {
|
|
58
|
+
const call = transcript.messages.flatMap(message => message.role === "assistant" ? message.content : [])
|
|
59
|
+
.find(block => block.type === "toolCall" && block.id === callId);
|
|
60
|
+
transcript.messages.push({
|
|
61
|
+
role: "toolResult", toolCallId: callId, toolName: call?.type === "toolCall" ? call.name : "tool",
|
|
62
|
+
content: [{ type: "text", text: reason }], isError: true, timestamp: Date.now(),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
transcript.running.clear();
|
|
66
|
+
transcript.finished = true;
|
|
67
|
+
if (!this.finishedIds.includes(executionId))
|
|
68
|
+
this.finishedIds.push(executionId);
|
|
69
|
+
while (this.finishedIds.length > this.maxFinished)
|
|
70
|
+
this.transcripts.delete(this.finishedIds.shift());
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Token and output bursts coalesce into at most one notification per 100 ms. */
|
|
74
|
+
update(executionId, change, burst = false) {
|
|
75
|
+
const transcript = this.transcripts.get(executionId);
|
|
76
|
+
if (!transcript || transcript.finished)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
change(transcript);
|
|
80
|
+
transcript.version++;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Display state must never affect node execution.
|
|
84
|
+
}
|
|
85
|
+
if (!burst)
|
|
86
|
+
this.changed();
|
|
87
|
+
else if (!this.pending) {
|
|
88
|
+
this.pending = setTimeout(() => this.changed(), 100);
|
|
89
|
+
this.pending.unref?.();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
changed() {
|
|
93
|
+
clearTimeout(this.pending);
|
|
94
|
+
this.pending = undefined;
|
|
95
|
+
try {
|
|
96
|
+
this.onChange();
|
|
97
|
+
}
|
|
98
|
+
catch { /* Observers cannot fail a worker. */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrok/pi-braid",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Pi extension for Braid: background agent graphs, bounded loops, live updates, and a flow panel",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"grok-mermaid": "^0.2.2",
|
|
9
|
-
"@chrok/braid": "0.3.
|
|
9
|
+
"@chrok/braid": "0.3.3"
|
|
10
10
|
},
|
|
11
11
|
"peerDependencies": {
|
|
12
12
|
"@earendil-works/pi-ai": "*",
|