@chrok/pi-braid 0.3.1 → 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/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,23 +475,111 @@ 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) {
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, "");
520
+ const timing = [
521
+ ...(node.model ? [`model ${compact(node.model, 60)}`] : []),
522
+ ...(node.iteration ? [`iteration #${node.iteration}`] : []),
523
+ ...(node.startedAt !== undefined || node.latencyMs !== undefined ? [`elapsed ${nodeElapsed(node, now)}`] : []),
524
+ ...(node.usage ? [`${compactCount(node.usage.inputTokens)} in / ${compactCount(node.usage.outputTokens)} out tokens`] : []),
525
+ ];
526
+ // Progress is tracked per node ID; only show it for the execution it describes.
527
+ const live = progress && (!progress.executionId || progress.executionId === node.executionId) ? progress : undefined;
528
+ const workspace = node.workspace;
529
+ // Cleaned-up worktrees are gone; their checkpoint ref is what can be recovered.
530
+ const workspaceActive = workspace && ["preparing", "ready", "failed"].includes(workspace.state);
531
+ const workspaceRef = workspaceActive ? workspace.worktreeRoot : workspace?.checkpointRef ?? workspace?.reason;
435
532
  const lines = [
436
533
  theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`),
437
534
  ...(node.executionId ? [theme.fg("dim", `execution: ${compact(node.executionId, 80)}`)] : []),
535
+ ...(timing.length ? [theme.fg("muted", timing.join(" · "))] : []),
536
+ ...(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`)] : []),
537
+ ...(workspace ? [theme.fg("dim", `workspace: ${workspace.mode} · ${workspace.state}${workspaceRef ? ` · ${compact(workspaceRef, 160)}` : ""}`)] : []),
438
538
  ...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
439
539
  ...(node.error ? [theme.fg("error", `${node.error.code}: ${clean(node.error.message)}`)] : []),
440
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) : []),
441
543
  ...(node.output ? [expanded ? clean(node.output) : compact(node.output, 400)] : []),
442
544
  ...(!expanded && node.output && (node.output.length > 400 || node.output.includes("\n")) ? [theme.fg("dim", "Expand for full node output")] : []),
443
545
  ...(fullOutputPath ? [theme.fg("dim", `full node result: ${compact(fullOutputPath, 240)}`)] : []),
444
546
  ];
445
547
  return new Text(lines.join("\n"), 0, 0);
446
548
  }
447
- 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) {
448
583
  if (isError || !result?.nodes) {
449
584
  const message = fallback ||
450
585
  (isPartial ? "Braid is running…" : "Braid returned no execution details");
@@ -480,8 +615,17 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
480
615
  if ("executions" in result)
481
616
  lines.push(theme.fg("dim", `${Object.keys(result.executions).length} executions · revision ${result.revision}`));
482
617
  const chartStart = lines.length;
483
- const chart = mermaidLines(result, theme);
618
+ const chart = mermaidLines(result, theme, selectedNodeId);
484
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
+ ];
485
629
  const workspaces = Object.values(result.workspaces ?? {}).filter(workspace => workspace.worktreeRoot);
486
630
  if (workspaces.length) {
487
631
  const active = workspaces.filter(workspace => ["preparing", "ready", "failed"].includes(workspace.state)).length;
@@ -497,5 +641,8 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
497
641
  lines.push(...renderEventLog(result.events ?? [], true, theme));
498
642
  if ("fullOutputPath" in result && result.fullOutputPath)
499
643
  lines.push(theme.fg("dim", `full result/log: ${compact(result.fullOutputPath, 240)}`));
500
- 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
+ ]);
501
648
  }
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ import { join } from "node:path";
5
5
  import { defineTool, truncateHead, } from "@earendil-works/pi-coding-agent";
6
6
  import { Text } from "@earendil-works/pi-tui";
7
7
  import { BraidJobs } from "./jobs.js";
8
- import { registerBraidCommand } from "./command.js";
8
+ import { registerBraidCommand, registerBraidWidget } from "./command.js";
9
+ import { registerReviewCommand } from "./review.js";
9
10
  import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js";
10
11
  const text = (description) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
11
12
  const prompt = () => Type.Union([
@@ -107,6 +108,8 @@ function isJobSnapshot(value) {
107
108
  ["running", "completed", "failed", "cancelled"].includes(job.status ?? "") &&
108
109
  !!job.live && typeof job.live.nodes === "object" && job.live.nodes !== null;
109
110
  }
111
+ /** Focused reads return the latest activity entries; activityBefore pages earlier ones. */
112
+ export const STATUS_ACTIVITY_ENTRIES = 40;
110
113
  function executionControl(job) {
111
114
  return job.execution && {
112
115
  status: job.execution.status,
@@ -201,11 +204,12 @@ export function createBraidTools(jobs) {
201
204
  jobId: Type.Optional(text("Exact session handle (e.g. job-1) or UUID; omit all IDs to list jobs.")),
202
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.")),
203
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.` })),
204
208
  }, { additionalProperties: false });
205
209
  const statusTool = defineTool({
206
210
  name: "braid_status",
207
211
  label: "Braid status",
208
- 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.",
209
213
  parameters: statusParameters,
210
214
  renderResult(result, options, theme, ctx) {
211
215
  const job = result.details;
@@ -224,8 +228,9 @@ export function createBraidTools(jobs) {
224
228
  const selectedNode = job.selectedNode ?? (executionId
225
229
  ? state && Object.hasOwn(state.executions, executionId) ? state.executions[executionId] : undefined
226
230
  : nodeId && state && Object.hasOwn(state.nodes, nodeId) ? state.nodes[nodeId] : undefined);
231
+ // Render at the read's observation time so a saved result never keeps counting.
227
232
  if (selectedNode)
228
- return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath);
233
+ return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath, job.live.progress?.[selectedNode.id], job.live.observedAt, job.selectedActivity);
229
234
  if (executionId || nodeId)
230
235
  return new Text(fallback || "Braid returned no node details", 0, 0);
231
236
  if (job.error)
@@ -240,6 +245,8 @@ export function createBraidTools(jobs) {
240
245
  async execute(_id, params) {
241
246
  if ((params.nodeId !== undefined || params.executionId !== undefined) && !params.jobId)
242
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");
243
250
  if (!params.jobId)
244
251
  return {
245
252
  content: [{ type: "text", text: JSON.stringify(jobs.list()) }],
@@ -251,9 +258,18 @@ export function createBraidTools(jobs) {
251
258
  if (params.nodeId !== undefined || params.executionId !== undefined) {
252
259
  const node = jobs.getNode(params.jobId, params.nodeId, params.executionId);
253
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 } : {};
254
268
  const full = JSON.stringify({
255
269
  jobId: job.jobId, handle: job.handle, status: job.status, execution: executionControl(job),
270
+ ...(running ? live : {}),
256
271
  node: { id, executionId, status, error, output, ...nodeDetails },
272
+ ...(running ? {} : live),
257
273
  }, null, 2);
258
274
  const preview = truncateHead(full);
259
275
  let suffix = "";
@@ -267,7 +283,7 @@ export function createBraidTools(jobs) {
267
283
  }
268
284
  // Focused reads do not claim the whole job's usage; final job retrieval does.
269
285
  return { content: [{ type: "text", text: preview.content + suffix }], details: {
270
- ...job, selectedNode: node, ...(nodeOutputPath ? { nodeOutputPath } : {}),
286
+ ...job, selectedNode: node, ...(activity ? { selectedActivity: activity } : {}), ...(nodeOutputPath ? { nodeOutputPath } : {}),
271
287
  } };
272
288
  }
273
289
  const { jobId, handle, status, error, usage: providerUsage, fullOutputPath, execution, ...snapshot } = job;
@@ -422,6 +438,8 @@ export default function braidExtension(pi) {
422
438
  });
423
439
  const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs);
424
440
  registerBraidCommand(pi, jobs);
441
+ registerReviewCommand(pi, jobs);
442
+ const disposeWidget = registerBraidWidget(pi, jobs);
425
443
  pi.registerTool(braidTool);
426
444
  pi.registerTool(statusTool);
427
445
  pi.registerTool(cancelTool);
@@ -430,6 +448,7 @@ export default function braidExtension(pi) {
430
448
  pi.on("session_shutdown", () => {
431
449
  pending.clear();
432
450
  jobs.dispose();
451
+ disposeWidget();
433
452
  });
434
453
  pi.on("before_agent_start", (event) => ({
435
454
  systemPrompt: `${event.systemPrompt}\n\n## Braid execution policy\n${BRAID_USAGE_GUIDANCE}\n\nFor the current user request, make this delegation choice before using read, grep, find, edit, write, or bash. Completion reminders refer to existing jobs: retrieve their results instead of submitting the same graph again.`,
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;
@@ -56,6 +57,9 @@ export class BraidJobs {
56
57
  controller: new AbortController(),
57
58
  done: Promise.resolve(),
58
59
  usageClaimed: false,
60
+ runningExecutions: new Map(),
61
+ activity: new ExecutionActivityTracker({ onChange: () => this.changed() }),
62
+ transcripts: new PiTranscripts(() => this.changed()),
59
63
  nodeResults: new Map(snapshot.nodes.map((node) => [node.id, { id: node.id, status: "pending" }])),
60
64
  };
61
65
  this.jobs.set(job.jobId, job);
@@ -78,6 +82,8 @@ export class BraidJobs {
78
82
  runner: (() => {
79
83
  const invoke = createPiRunner(registry, {
80
84
  cwd,
85
+ activity: job.activity,
86
+ transcripts: job.transcripts,
81
87
  maxToolRounds: options.maxToolRounds ?? Infinity,
82
88
  maxToolCalls: options.maxToolCalls ?? Infinity,
83
89
  onUsage: (usage) => {
@@ -91,15 +97,35 @@ export class BraidJobs {
91
97
  this.changed();
92
98
  },
93
99
  });
94
- 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
+ };
95
110
  })(),
96
111
  onEvent: (event) => {
112
+ job.activity.observe(event);
97
113
  if (event.type === "workspace_updated") {
98
114
  job.workspaces ??= {};
99
115
  Object.defineProperty(job.workspaces, event.workspace.executionId ?? event.workspace.nodeId, {
100
116
  value: { ...event.workspace }, enumerable: true, configurable: true, writable: true,
101
117
  });
102
118
  }
119
+ // Definition edits can remove nodes whose executions are still running.
120
+ if (event.type === "node_started" && event.executionId) {
121
+ job.runningExecutions.set(event.executionId, event.nodeId);
122
+ }
123
+ else if ((event.type === "node_completed" || event.type === "node_failed") && event.executionId) {
124
+ job.runningExecutions.delete(event.executionId);
125
+ }
126
+ else if (event.type === "graph_completed" || event.type === "graph_failed") {
127
+ job.runningExecutions.clear();
128
+ }
103
129
  applyEvent(job.live, event);
104
130
  if ("nodeId" in event && Object.hasOwn(job.live.nodes, event.nodeId)) {
105
131
  // The panel keeps short previews; node retrieval needs the complete
@@ -161,6 +187,7 @@ export class BraidJobs {
161
187
  job.status = "failed";
162
188
  job.live.status = "failed";
163
189
  job.live.pausedExecutionIds = [];
190
+ job.runningExecutions.clear();
164
191
  job.error = error instanceof Error ? error.message : String(error);
165
192
  if (reports.length)
166
193
  job.usage = sumPiUsage(reports);
@@ -186,7 +213,7 @@ export class BraidJobs {
186
213
  const job = this.lookup(jobId);
187
214
  if (!job)
188
215
  return undefined;
189
- const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, ...snapshot } = job;
216
+ const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, ...snapshot } = job;
190
217
  const copy = structuredClone(snapshot);
191
218
  if (job.run)
192
219
  copy.execution = job.run.snapshot();
@@ -208,6 +235,20 @@ export class BraidJobs {
208
235
  throw new Error(`Unknown Braid node or execution in ${job.handle}. Use braid_status with only jobId to list executions.`);
209
236
  return structuredClone(node);
210
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
+ }
211
252
  update(jobId, patch) {
212
253
  const job = this.lookup(jobId);
213
254
  if (!job)
@@ -239,6 +280,20 @@ export class BraidJobs {
239
280
  }))
240
281
  .reverse();
241
282
  }
283
+ /** Editor progress without copying outputs, transcripts, or execution snapshots. */
284
+ progress(jobId) {
285
+ const job = this.lookup(jobId);
286
+ if (!job)
287
+ return undefined;
288
+ const nodes = Object.values(job.live.nodes);
289
+ return {
290
+ total: nodes.length,
291
+ done: nodes.filter(node => node.status === "completed" || node.status === "failed" || node.status === "skipped").length,
292
+ failed: nodes.filter(node => node.status === "failed").length,
293
+ running: [...new Set(job.runningExecutions.values())],
294
+ paused: job.live.pausedExecutionIds?.length ?? 0,
295
+ };
296
+ }
242
297
  cancel(jobId) {
243
298
  const job = this.lookup(jobId);
244
299
  if (!job)
package/dist/review.js ADDED
@@ -0,0 +1,64 @@
1
+ const maxRounds = 3;
2
+ export function registerReviewCommand(pi, jobs) {
3
+ pi.registerCommand("braid:review", {
4
+ description: "Parallel review/fix loop, at most 3 review rounds: /braid:review [target or focus]",
5
+ handler: async (args, ctx) => {
6
+ const target = args.trim() || "Current uncommitted changes; if there are none, the latest commit.";
7
+ const input = {
8
+ goal: `Review the following target, fix concrete in-scope defects, and re-review material fixes. Stop after at most ${maxRounds} review rounds. Respect an explicit review-only request.\n\nTarget or focus:\n${target}`,
9
+ promptTemplates: {
10
+ reviewer: `You are reviewer {{slot}}. Use the distinct angle assigned to you by scope.
11
+ Inspect the repository instructions, actual files, and target diff in your assigned snapshot. Do not rely on conversation history or edit files. Use scope's pinned original baseline and target endpoint for the original target diff, and separately compare its pinned initial job snapshot to YOUR workspace.snapshotCommit for cumulative loop fixes. Never substitute the current snapshot for the original target endpoint: historical ranges and latest-commit reviews exclude later commits and unrelated caller edits, even within target files. Inspect current files for fix validity, but attribute findings only to the original target diff or cumulative fix diff. Plain git diff may be empty because caller edits were captured in a commit.
12
+ Report only concrete current issues within the target, supported by source proof, a test/repro, or a contract contradiction. For diff reviews, require that the issue is caused or made reachable by the diff. Include file/line references, evidence, a suggested fix, and P0/P1/P2: P0 blocks merge, P1 should be fixed before release, P2 is report-only. Do not filter the first pass to blockers only or invent findings to fill your angle.
13
+ On follow-up rounds check whether earlier findings were resolved, whether the fixes introduced defects in their blast radius, and whether deferred notes still stand. You cannot run tests here; distinguish source inspection from executed validation.
14
+ If nothing qualifies, say exactly "No issues found." End with "Merge verdict: BLOCK", "Merge verdict: OK", or "Merge verdict: OK with notes".`,
15
+ },
16
+ nodes: [
17
+ {
18
+ type: "execute", id: "scope", workspace: "worktree", requireSuccess: true,
19
+ prompt: `Prepare this review round by inspecting the requested target and relevant repository instructions. You are the only fix writer.
20
+ On the FIRST round only, resolve the target without editing files. For the default target, compare workspace.baseCommit (the caller's original HEAD) to workspace.snapshotCommit, including captured staged/unstaged/non-ignored new files. If identical, review the latest original commit against its parent; for an initial/unborn commit inspect all captured files. Do not mistake a clean isolated worktree for an empty target. For an explicit commit/range target, resolve HEAD against workspace.baseCommit, not the detached snapshot HEAD, and pin all endpoints as commit hashes.
21
+ Pin the original baseline commit, original target endpoint commit, initial job snapshot commit (first-round workspace.snapshotCommit), and original target/file scope in the first handoff. For captured uncommitted changes the original target endpoint is the initial job snapshot; for latest-commit review it is workspace.baseCommit; for an explicit range it is the resolved upper endpoint. On later rounds retain all these exact values from the previous check handoff; never re-resolve HEAD-relative refs or switch target modes. Inspect two separate diffs: original baseline to original target endpoint, and initial job snapshot to current workspace.snapshotCommit for cumulative loop fixes. Do not review baseline-to-current-snapshot as the target: file filtering cannot exclude later commits or unrelated dirty edits in the same target file. Preserve those out-of-target edits and do not fix their defects.
22
+ On RETRY rounds, apply only the concrete, evidenced in-scope P0/P1 fixes synthesized by the previous check. Preserve all user edits and any review-only constraint. No optional polish, scope expansion, architecture decisions, or recursive agents. Use only your own worktree. Run focused validation, repair failures within scope, and report commands with exit codes, evidence, and limitations. If validation remains unsuccessful or no material fix was possible, say so explicitly; do not claim success.
23
+ Choose three distinct, concrete review angles for this change (usually correctness/regressions, tests/contracts, and simplicity; adapt for security, performance, UI/accessibility, or docs when relevant). Assign them to reviewers 1, 2, and 3. Carry forward cumulative fixes, changed files, validation results, unresolved/deferred findings, and current execution.iteration. If a requested target cannot be read with your tools, report that limitation instead of substituting another target. Outside Git, report findings without attempting fixes.`,
24
+ },
25
+ ...[1, 2, 3].map(slot => ({
26
+ type: "execute", id: `review-${slot}`, workspace: "read-only", requireSuccess: true,
27
+ prompt: { template: "reviewer", variables: { slot: String(slot) } },
28
+ })),
29
+ {
30
+ type: "decision", id: "check", workspace: "read-only", requireSuccess: true, choices: ["retry", "done"],
31
+ prompt: `Synthesize scope and all three reviews. Verify and deduplicate findings against actual files in YOUR workspace.snapshotCommit, which includes scope's completed fixes; separate valid P0/P1 fixes worth doing now, P2 report-only notes, and feedback to ignore/defer with reasons. Do not blindly apply suggestions or add optional polish.
32
+ You are read-only and cannot edit files or run tests. If the target is unavailable, review-only was requested, nothing actionable remains, fixes are unavailable outside Git, or a fix requires an unapproved product/scope/architecture decision, call decide with done. Report the exact stop reason and unresolved findings.
33
+ If the previous fix pass failed validation or made no material progress, stop with done and prohibit integration of unvalidated or unreviewed fixes; do not repeatedly retry the same findings.
34
+ There are at most ${maxRounds} review rounds, including the first. If execution.iteration >= ${maxRounds}, call decide with done; distinguish round-limit exhaustion from a clean review. Never choose retry in the final round.
35
+ Otherwise choose retry only when concrete in-scope P0/P1 fixes are worth doing now. Give scope a narrow, deduplicated fix list with evidence so the next writer pass addresses only those findings before fresh reviewers inspect its checkpoint.
36
+ Your final handoff must retain the pinned original baseline, original target endpoint, initial job snapshot, and target/file scope unchanged, cumulative fixes and changed files from scope, commands and exit codes, validation evidence/limitations, unresolved and deferred findings, whether the cumulative fixes passed review and validation and may be integrated, rounds run, and stop reason. Prohibit integration when unresolved findings invalidate those fixes. Call decide exactly once, then give this handoff.`,
37
+ },
38
+ {
39
+ type: "integrate", id: "apply", requireSuccess: true,
40
+ prompt: `Read the final check handoff and inspect the cumulative diff in the merge source. Apply only fixes that passed follow-up review and focused validation; do not implement new fixes here. If review-only, no fixes exist, validation failed, or the handoff prohibits integration, leave the source files unchanged and mark the source discarded or archived as appropriate.
41
+ Use mergeSources.changes.baseCommit (the initial job snapshot, including caller edits) to checkpointRef as the patch baseline. Do not diff from original HEAD or replay pre-existing caller changes. Preserve staged, unstaged, untracked, and concurrent user edits. If new checkout edits conflict, archive the checkpoint and report the conflict; do not create an unreviewed resolution or overwrite user work. Call finish_merge exactly once with the source executionId and an accurate disposition after applying or archiving the changes.
42
+ Inspect the final diff and run or confirm focused validation without changing code. Summarize rounds run, fixes actually applied, validation, remaining/deferred findings, integration outcome, and why the loop stopped. Report round-limit or needs-decision exits honestly; never claim they are clean.`,
43
+ },
44
+ ],
45
+ edges: [
46
+ ...[1, 2, 3].flatMap(slot => [
47
+ { from: "scope", to: `review-${slot}` },
48
+ { from: `review-${slot}`, to: "check" },
49
+ ]),
50
+ { from: "scope", to: "check" },
51
+ { from: "check", to: "scope", choice: "retry", feedback: "review-loop" },
52
+ { from: "check", to: "apply", choice: "done" },
53
+ ],
54
+ loops: [{ id: "review-loop", entry: "scope", maxIterations: maxRounds }],
55
+ };
56
+ const job = jobs.start(input, { maxConcurrency: 3, maxExecutions: maxRounds * 5 + 1 }, ctx);
57
+ pi.sendMessage({
58
+ customType: "braid-review-started", display: true,
59
+ content: `Review started as Braid ${job.handle}: ${target}\nUp to ${maxRounds} parallel review rounds and ${maxRounds - 1} fix passes. Only reviewed fixes are integrated. Open /braid ${job.handle} for progress, or braid_cancel to stop. Wait for the completion reminder, then retrieve braid_status({"jobId":"${job.handle}"}) and summarize; do not start a replacement review loop.`,
60
+ details: { jobId: job.jobId, handle: job.handle },
61
+ }, { triggerTurn: false });
62
+ },
63
+ });
64
+ }