@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 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
- Run `/braid <jobId> <nodeId>` to open the panel on one node's latest invocation,
229
- or `/braid <jobId> <executionId>` for an exact invocation such as an earlier loop
230
- iteration. The node view refreshes live with its status, model, elapsed time,
231
- token usage, context size, tool-call count, workspace, and full output or error.
232
- Press Backspace to return to the whole graph; switching jobs also returns to it.
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, renderNodeResult } from "./display.js";
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
- /** A live, scrollable panel. Closing the panel leaves jobs running. */
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
- const content = current && this.focus
96
- ? this.renderFocus(current, this.focus, contentWidth)
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 = rows - header.length - 4;
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 help = this.focus
120
- ? contentWidth >= 72
121
- ? "⌫ graph ←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close"
122
- : contentWidth >= 42
123
- ? "⌫ graph · ↑/↓ scroll · c cancel · Esc close"
124
- : "⌫ graph · Esc close"
125
- : contentWidth >= 72
126
- ? "←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close"
127
- : contentWidth >= 42
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
- this.theme.fg("warning", "This node or execution is no longer in the job's graph."),
150
- this.theme.fg("muted", "Press Backspace to return to the graph."),
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
- return renderNodeResult(iteration === undefined ? node : { ...node, iteration }, true, this.theme, undefined, job.live.progress[node.id], job.live.observedAt)
158
- .render(width);
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, "escape") ||
162
- data === "q" ||
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
- if (matchesKey(data, "left") || matchesKey(data, "right")) {
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 if (matchesKey(data, "backspace") && this.focus) {
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, "up"))
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 === "c" && this.selected)
189
- this.jobs.cancel(this.selected);
190
- this.refresh();
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
- constructor(lines, chartStart = lines.length, chartLength = 0) {
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
- if (chart.length === 0)
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
- const chartOutput = chartWidth <= width
20
- ? chart
21
- : [
22
- truncateToWidth(`[Flowchart needs ${chartWidth} columns; terminal width is ${width}. Expand your terminal to see it.]`, width, ""),
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
- function mermaidLabelLines(id, result) {
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
- const art = renderMermaid(mermaidSource(result));
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
- export function renderGraphResult(result, expanded, isPartial, theme, fallback = "", isError = false) {
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) => { await nextTurn(); return invoke(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
- // Registry.complete resolves stored API keys, OAuth, custom headers and endpoints.
175
- const response = await registry.complete(model, context, {
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, undefined);
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
- if (call.name === "decide") {
291
- if (request.node.type !== "decision") {
292
- results.push(toolResult(call, "decide is available only on decision nodes", true));
293
- continue;
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
- else {
308
- results.push(await executeFileTool(call));
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.2",
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.2"
9
+ "@chrok/braid": "0.3.3"
10
10
  },
11
11
  "peerDependencies": {
12
12
  "@earendil-works/pi-ai": "*",