@chrok/pi-braid 0.3.1 → 0.3.2

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
@@ -25,6 +25,7 @@ It registers:
25
25
  - `braid_update` / `braid_resume` — edit live definitions or release paused executions.
26
26
  - `braid_cancel` — cancel a job with `{ "jobId": "..." }`.
27
27
  - `/braid [jobId]` — open a live flow panel in interactive Pi.
28
+ - `/braid:review [target or focus]` — run a bounded parallel review/fix loop.
28
29
 
29
30
  Submission and completion reminders use short session handles such as `job-1`.
30
31
  Status, cancellation and the panel accept either that exact handle or the original
@@ -50,6 +51,44 @@ or switching/forking sessions aborts outstanding work and suppresses its
50
51
  reminders. Job IDs cannot be retrieved after that lifecycle ends. They are not
51
52
  persistent processes outside Pi.
52
53
 
54
+ ## Review command
55
+
56
+ Run `/braid:review` to review current uncommitted changes (including staged, unstaged,
57
+ and non-ignored new files). With no uncommitted changes, it reviews the latest
58
+ commit. Supply a file, commit range, or focus with `/braid:review src/runtime.ts` or
59
+ `/braid:review main..HEAD, focus on cancellation`. Workers inspect the supplied target
60
+ with local tools; they report unavailable remote targets instead of inventing
61
+ their contents. Use `/braid:review review-only, src/runtime.ts` to request findings
62
+ without fixes.
63
+
64
+ Each round assigns three independent, read-only reviewers distinct angles based
65
+ on the change. Findings need concrete evidence and file/line references, with
66
+ P0/P1/P2 priority and a merge verdict. One writer synthesizes the feedback and
67
+ fixes only evidenced, in-scope P0/P1 problems in an isolated worktree, then runs
68
+ focused validation. Optional P2 polish is reported. A product, scope, or
69
+ architecture decision stops the loop for user input.
70
+
71
+ The graph enforces a fixed maximum of **three review rounds**, including the
72
+ first, and **two fix passes**. The final round reports remaining issues without
73
+ making further edits; reaching the cap does not mean the review is clean.
74
+ Only fixes that passed re-review and validation are integrated into the invoking
75
+ checkout, preserving existing user changes. Conflicting concurrent edits or
76
+ failed validation leave the fix checkpoint archived for recovery. Outside Git,
77
+ workers can only inspect files and report findings.
78
+
79
+ The command returns immediately with a job handle. Use `/braid [jobId]` to see
80
+ progress, `braid_status` for results, and `braid_cancel` to stop. The existing
81
+ completion reminder resumes the parent to report rounds, applied fixes,
82
+ validation, remaining findings, and the stop reason. Session shutdown cancels
83
+ the job. Cancellation or failure during integration can leave partial checkout
84
+ changes; Braid retains backup and checkpoint refs for recovery.
85
+
86
+ The review policy is adapted from pi-subagents'
87
+ [review-loop](https://github.com/nicobailon/pi-subagents/blob/main/prompts/review-loop.md)
88
+ and [parallel-review](https://github.com/nicobailon/pi-subagents/blob/main/prompts/parallel-review.md)
89
+ prompts. Invoking `/braid:review` authorizes the narrow fix passes described above;
90
+ an explicit review-only request disables them.
91
+
53
92
  ## Graph definitions
54
93
 
55
94
  Each node has a unique `id` and one of these four shapes:
@@ -163,6 +202,21 @@ scheduler persistence across Pi reloads. Reminders alone do not pause execution.
163
202
 
164
203
  ## Live flow panel
165
204
 
205
+ In interactive Pi, background progress appears automatically **above the input
206
+ editor** as soon as a job is submitted; no command or polling is needed:
207
+
208
+ ```text
209
+ Braid job-1 · running · 2/5 done · review
210
+ ```
211
+
212
+ The widget shows node progress, active node IDs, paused gates, and failures.
213
+ It shows up to three active jobs, with an overflow count for the rest. When no
214
+ jobs are active, the newest job's final completed/failed/cancelled status stays
215
+ visible until another job is submitted or the session ends. Counts use the
216
+ latest state of each current node definition; failed and skipped nodes count as
217
+ done, and reruns or live edits can change progress and totals. RPC, JSON, and
218
+ print modes do not create a terminal widget.
219
+
166
220
  Run `/braid` to open the newest job, or `/braid <jobId>` to open a specific job.
167
221
  The bordered panel keeps the job header and keyboard controls visible while
168
222
  you scroll the flow and event log. It refreshes as nodes start, finish, fail,
@@ -171,6 +225,17 @@ Use Left/Right to select jobs, Up/Down or Page Up/Page Down to scroll, `c` to
171
225
  cancel the selected job, and Escape or `q` to close the panel. Closing the panel
172
226
  leaves jobs running. In RPC or noninteractive modes, use `braid_status`.
173
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.
233
+ Tab completes job handles and then that job's node IDs.
234
+ IDs with leading/trailing whitespace, terminal controls, or a leading double
235
+ quote use JSON string syntax, such as `/braid job-1 " review "` or
236
+ `/braid job-1 "review\nstep"`. Tab inserts this form automatically and displays
237
+ control characters as escapes.
238
+
174
239
  The panel renders a Mermaid flowchart, node states, elapsed times, context-token
175
240
  estimates or provider-reported usage, context-window sizes, filesystem tool-call
176
241
  counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status
package/dist/command.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
2
- import { renderGraphResult } from "./display.js";
2
+ import { renderGraphResult, renderNodeResult } from "./display.js";
3
3
  // Match the overlay height exactly so Pi never clips the footer or bottom border.
4
4
  const panelHeight = (rows) => Math.max(1, Math.floor(rows * 0.9));
5
5
  const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
@@ -10,17 +10,19 @@ export class BraidPanel {
10
10
  theme;
11
11
  done;
12
12
  selected;
13
+ focus;
13
14
  offset = 0;
14
15
  maxOffset = 0;
15
16
  disposed = false;
16
17
  unsubscribe;
17
18
  ticker;
18
- constructor(jobs, tui, theme, done, jobId) {
19
+ constructor(jobs, tui, theme, done, jobId, focus) {
19
20
  this.jobs = jobs;
20
21
  this.tui = tui;
21
22
  this.theme = theme;
22
23
  this.done = done;
23
24
  this.selected = jobId ? jobs.get(jobId)?.jobId ?? jobId : undefined;
25
+ this.focus = focus;
24
26
  this.unsubscribe = jobs.subscribe(() => this.refresh());
25
27
  this.ticker = setInterval(() => this.refresh(), 1000);
26
28
  this.ticker.unref();
@@ -60,8 +62,11 @@ export class BraidPanel {
60
62
  : current?.status === "failed"
61
63
  ? "error"
62
64
  : "muted";
65
+ const focusLabel = this.focus
66
+ ? "nodeId" in this.focus ? `node ${plain(this.focus.nodeId)}` : `execution ${plain(this.focus.executionId)}`
67
+ : "";
63
68
  const heading = current
64
- ? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}`
69
+ ? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}${focusLabel ? ` · ${focusLabel}` : ""}`
65
70
  : this.theme.fg("muted", "No background jobs");
66
71
  // Reserve a body row and the close hint even in a very short terminal.
67
72
  if (rows < 10) {
@@ -87,21 +92,23 @@ export class BraidPanel {
87
92
  : "Jobs will appear here as soon as they are submitted.")),
88
93
  rule("├", "┤"),
89
94
  ];
90
- const content = current
91
- ? [
92
- ...(current.error
93
- ? [this.theme.fg("error", plain(current.error))]
94
- : []),
95
- ...renderGraphResult({
96
- ...(current.result ?? current.live),
97
- progress: current.live.progress,
98
- ...(current.workspaces ? { workspaces: current.workspaces } : {}),
99
- ...(current.fullOutputPath
100
- ? { fullOutputPath: current.fullOutputPath }
101
- : {}),
102
- }, true, false, this.theme).render(contentWidth),
103
- ]
104
- : ["No Braid jobs in this session."];
95
+ const content = current && this.focus
96
+ ? this.renderFocus(current, this.focus, contentWidth)
97
+ : current
98
+ ? [
99
+ ...(current.error
100
+ ? [this.theme.fg("error", plain(current.error))]
101
+ : []),
102
+ ...renderGraphResult({
103
+ ...(current.result ?? current.live),
104
+ progress: current.live.progress,
105
+ ...(current.workspaces ? { workspaces: current.workspaces } : {}),
106
+ ...(current.fullOutputPath
107
+ ? { fullOutputPath: current.fullOutputPath }
108
+ : {}),
109
+ }, true, false, this.theme).render(contentWidth),
110
+ ]
111
+ : ["No Braid jobs in this session."];
105
112
  const height = rows - header.length - 4;
106
113
  this.maxOffset = Math.max(0, content.length - height);
107
114
  this.offset = Math.min(this.offset, this.maxOffset);
@@ -109,11 +116,17 @@ export class BraidPanel {
109
116
  while (body.length < height)
110
117
  body.push("");
111
118
  const range = `Lines ${this.offset + 1}–${Math.min(content.length, this.offset + height)}/${content.length}`;
112
- const help = contentWidth >= 72
113
- ? "←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close"
114
- : contentWidth >= 42
115
- ? "←/→ jobs · ↑/↓ scroll · c cancel · Esc close"
116
- : "↑/↓ scroll · Esc close";
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";
117
130
  return [
118
131
  ...header,
119
132
  ...body.map(row),
@@ -123,6 +136,27 @@ export class BraidPanel {
123
136
  rule("╰", "╯"),
124
137
  ];
125
138
  }
139
+ /** A focused node can disappear after a live graph update; say so instead of failing. */
140
+ renderFocus(job, focus, width) {
141
+ let node;
142
+ try {
143
+ node = "nodeId" in focus
144
+ ? this.jobs.getNode(job.jobId, focus.nodeId)
145
+ : this.jobs.getNode(job.jobId, undefined, focus.executionId);
146
+ }
147
+ 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
+ ];
152
+ }
153
+ // Latest-node results omit the loop iteration; their execution record has it.
154
+ const executions = job.result?.executions ?? job.execution?.executions;
155
+ const iteration = node.iteration ??
156
+ (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);
159
+ }
126
160
  handleInput(data) {
127
161
  if (matchesKey(data, "escape") ||
128
162
  data === "q" ||
@@ -136,6 +170,11 @@ export class BraidPanel {
136
170
  if (matchesKey(data, "left") || matchesKey(data, "right")) {
137
171
  const step = matchesKey(data, "right") ? 1 : -1;
138
172
  this.selected = jobs[(index + step + jobs.length) % jobs.length]?.jobId;
173
+ this.focus = undefined;
174
+ this.offset = 0;
175
+ }
176
+ else if (matchesKey(data, "backspace") && this.focus) {
177
+ this.focus = undefined;
139
178
  this.offset = 0;
140
179
  }
141
180
  else if (matchesKey(data, "up"))
@@ -159,19 +198,76 @@ export class BraidPanel {
159
198
  this.unsubscribe();
160
199
  }
161
200
  }
201
+ export function registerBraidWidget(pi, jobs) {
202
+ let dispose = () => { };
203
+ const clear = () => {
204
+ dispose();
205
+ dispose = () => { };
206
+ };
207
+ pi.on("session_start", (_event, ctx) => {
208
+ clear();
209
+ if (ctx.mode !== "tui")
210
+ return;
211
+ const refresh = () => {
212
+ if (!jobs.list().length) {
213
+ ctx.ui.setWidget("braid-status", undefined);
214
+ return;
215
+ }
216
+ ctx.ui.setWidget("braid-status", (_tui, theme) => ({
217
+ render(width) {
218
+ const all = jobs.list();
219
+ const active = all.filter(job => job.status === "running");
220
+ const selected = active.length ? active.slice(0, 3) : all.slice(0, 1);
221
+ const lines = selected.map(job => {
222
+ const progress = jobs.progress(job.jobId);
223
+ const status = job.status === "running" && progress.paused && !progress.running.length
224
+ ? "paused" : job.status;
225
+ const color = status === "completed" ? "success"
226
+ : status === "failed" ? "error"
227
+ : status === "cancelled" ? "muted" : "warning";
228
+ const parts = [
229
+ theme.fg("accent", `Braid ${job.handle}`),
230
+ theme.fg(color, status),
231
+ `${progress.done}/${progress.total} done`,
232
+ ];
233
+ if (progress.failed)
234
+ parts.push(theme.fg("error", `${progress.failed} failed`));
235
+ if (progress.paused && status !== "paused")
236
+ parts.push(theme.fg("warning", `${progress.paused} paused`));
237
+ parts.push(plain(progress.running.length ? progress.running.join(", ") : job.goal));
238
+ return truncateToWidth(parts.join(" · "), width, "…");
239
+ });
240
+ if (active.length > selected.length)
241
+ lines.push(truncateToWidth(theme.fg("muted", `Braid · +${active.length - selected.length} active jobs · /braid details`), width, "…"));
242
+ return lines;
243
+ },
244
+ invalidate() { },
245
+ }), { placement: "aboveEditor" });
246
+ };
247
+ const unsubscribe = jobs.subscribe(refresh);
248
+ dispose = () => {
249
+ unsubscribe();
250
+ ctx.ui.setWidget("braid-status", undefined);
251
+ };
252
+ refresh();
253
+ });
254
+ return clear;
255
+ }
162
256
  export function registerBraidCommand(pi, jobs) {
163
257
  pi.registerCommand("braid", {
164
- description: "Open the live background-job flow panel: /braid [jobId]",
258
+ description: "Open the live background-job flow panel: /braid [jobId [nodeId|executionId]]",
259
+ getArgumentCompletions: (prefix) => braidCompletions(jobs, prefix),
165
260
  handler: async (args, ctx) => {
166
261
  if (ctx.mode !== "tui")
167
262
  throw new Error("The Braid panel requires interactive Pi. Use braid_status for job status.");
168
- const jobId = args.trim() || undefined;
263
+ const [jobId, target] = splitArguments(args.trim());
169
264
  if (jobId && !jobs.get(jobId))
170
265
  throw jobs.unknownJob(jobId);
266
+ const focus = jobId && target ? resolveFocus(jobs, jobId, target) : undefined;
171
267
  let panel;
172
268
  try {
173
269
  await ctx.ui.custom((tui, theme, _keys, done) => {
174
- panel = new BraidPanel(jobs, tui, theme, done, jobId);
270
+ panel = new BraidPanel(jobs, tui, theme, done, jobId, focus);
175
271
  return panel;
176
272
  }, {
177
273
  overlay: true,
@@ -188,3 +284,65 @@ export function registerBraidCommand(pi, jobs) {
188
284
  },
189
285
  });
190
286
  }
287
+ /** Job IDs never contain whitespace; node IDs may, so the rest is one target. */
288
+ function splitArguments(text) {
289
+ const split = text.search(/\s/);
290
+ if (split < 0)
291
+ return [text || undefined, undefined];
292
+ const jobId = text.slice(0, split);
293
+ const target = text.slice(split).trim();
294
+ if (!target.startsWith('"'))
295
+ return [jobId, target || undefined];
296
+ try {
297
+ return [jobId, JSON.parse(target)];
298
+ }
299
+ catch {
300
+ throw new Error('A quoted Braid target must be a valid JSON string, for example " review ".');
301
+ }
302
+ }
303
+ /** Quote IDs that would otherwise be trimmed, parsed as quotes, or rendered as controls. */
304
+ function formatTarget(id) {
305
+ if (id === id.trim() && !id.startsWith('"') && !/[\p{Cc}\u2028\u2029]/u.test(id))
306
+ return id;
307
+ return JSON.stringify(id).replace(/\u2028/gu, "\\u2028").replace(/\u2029/gu, "\\u2029");
308
+ }
309
+ /** Node IDs take precedence; anything else must be an exact execution ID. */
310
+ function resolveFocus(jobs, jobId, target) {
311
+ try {
312
+ jobs.getNode(jobId, target);
313
+ return { nodeId: target };
314
+ }
315
+ catch { }
316
+ try {
317
+ jobs.getNode(jobId, undefined, target);
318
+ return { executionId: target };
319
+ }
320
+ catch { }
321
+ const handle = jobs.get(jobId)?.handle ?? jobId;
322
+ throw new Error(`Unknown Braid node or execution "${plain(target)}" in ${handle}. Open /braid ${handle} to see its nodes.`);
323
+ }
324
+ /** Pi replaces the whole argument text, so node suggestions repeat the job handle. */
325
+ function braidCompletions(jobs, prefix) {
326
+ const text = prefix.trimStart();
327
+ const split = text.search(/\s/);
328
+ const jobPrefix = split < 0 ? text : text.slice(0, split);
329
+ if (split < 0) {
330
+ const items = jobs.list()
331
+ .filter((job) => job.handle.startsWith(jobPrefix))
332
+ .map((job) => ({ value: job.handle, label: job.handle, description: `${job.status} · ${plain(job.goal)}` }));
333
+ return items.length ? items : null;
334
+ }
335
+ const nodePrefix = text.slice(split).trimStart();
336
+ const job = jobs.get(jobPrefix);
337
+ if (!job)
338
+ return null;
339
+ const nodes = job.result?.nodes ?? job.execution?.nodes ?? job.live.nodes;
340
+ const items = Object.values(nodes)
341
+ .flatMap((node) => {
342
+ const target = formatTarget(node.id);
343
+ return node.id.startsWith(nodePrefix) || target.startsWith(nodePrefix)
344
+ ? [{ value: `${job.handle} ${target}`, label: target, description: node.status }]
345
+ : [];
346
+ });
347
+ return items.length ? items : null;
348
+ }
package/dist/display.js CHANGED
@@ -429,12 +429,27 @@ export function applyProgress(state, progress) {
429
429
  });
430
430
  }
431
431
  /** Focused status reads must show the requested execution, including historical ones. */
432
- export function renderNodeResult(node, expanded, theme, fullOutputPath) {
432
+ export function renderNodeResult(node, expanded, theme, fullOutputPath, progress, now = Date.now()) {
433
433
  const failed = node.status === "failed";
434
434
  const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
435
+ const timing = [
436
+ ...(node.model ? [`model ${compact(node.model, 60)}`] : []),
437
+ ...(node.iteration ? [`iteration #${node.iteration}`] : []),
438
+ ...(node.startedAt !== undefined || node.latencyMs !== undefined ? [`elapsed ${nodeElapsed(node, now)}`] : []),
439
+ ...(node.usage ? [`${compactCount(node.usage.inputTokens)} in / ${compactCount(node.usage.outputTokens)} out tokens`] : []),
440
+ ];
441
+ // Progress is tracked per node ID; only show it for the execution it describes.
442
+ const live = progress && (!progress.executionId || progress.executionId === node.executionId) ? progress : undefined;
443
+ const workspace = node.workspace;
444
+ // Cleaned-up worktrees are gone; their checkpoint ref is what can be recovered.
445
+ const workspaceActive = workspace && ["preparing", "ready", "failed"].includes(workspace.state);
446
+ const workspaceRef = workspaceActive ? workspace.worktreeRoot : workspace?.checkpointRef ?? workspace?.reason;
435
447
  const lines = [
436
448
  theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`),
437
449
  ...(node.executionId ? [theme.fg("dim", `execution: ${compact(node.executionId, 80)}`)] : []),
450
+ ...(timing.length ? [theme.fg("muted", timing.join(" · "))] : []),
451
+ ...(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`)] : []),
452
+ ...(workspace ? [theme.fg("dim", `workspace: ${workspace.mode} · ${workspace.state}${workspaceRef ? ` · ${compact(workspaceRef, 160)}` : ""}`)] : []),
438
453
  ...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
439
454
  ...(node.error ? [theme.fg("error", `${node.error.code}: ${clean(node.error.message)}`)] : []),
440
455
  ...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []),
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([
@@ -224,8 +225,9 @@ export function createBraidTools(jobs) {
224
225
  const selectedNode = job.selectedNode ?? (executionId
225
226
  ? state && Object.hasOwn(state.executions, executionId) ? state.executions[executionId] : undefined
226
227
  : nodeId && state && Object.hasOwn(state.nodes, nodeId) ? state.nodes[nodeId] : undefined);
228
+ // Render at the read's observation time so a saved result never keeps counting.
227
229
  if (selectedNode)
228
- return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath);
230
+ return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath, job.live.progress?.[selectedNode.id], job.live.observedAt);
229
231
  if (executionId || nodeId)
230
232
  return new Text(fallback || "Braid returned no node details", 0, 0);
231
233
  if (job.error)
@@ -422,6 +424,8 @@ export default function braidExtension(pi) {
422
424
  });
423
425
  const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs);
424
426
  registerBraidCommand(pi, jobs);
427
+ registerReviewCommand(pi, jobs);
428
+ const disposeWidget = registerBraidWidget(pi, jobs);
425
429
  pi.registerTool(braidTool);
426
430
  pi.registerTool(statusTool);
427
431
  pi.registerTool(cancelTool);
@@ -430,6 +434,7 @@ export default function braidExtension(pi) {
430
434
  pi.on("session_shutdown", () => {
431
435
  pending.clear();
432
436
  jobs.dispose();
437
+ disposeWidget();
433
438
  });
434
439
  pi.on("before_agent_start", (event) => ({
435
440
  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
@@ -56,6 +56,7 @@ export class BraidJobs {
56
56
  controller: new AbortController(),
57
57
  done: Promise.resolve(),
58
58
  usageClaimed: false,
59
+ runningExecutions: new Map(),
59
60
  nodeResults: new Map(snapshot.nodes.map((node) => [node.id, { id: node.id, status: "pending" }])),
60
61
  };
61
62
  this.jobs.set(job.jobId, job);
@@ -100,6 +101,16 @@ export class BraidJobs {
100
101
  value: { ...event.workspace }, enumerable: true, configurable: true, writable: true,
101
102
  });
102
103
  }
104
+ // Definition edits can remove nodes whose executions are still running.
105
+ if (event.type === "node_started" && event.executionId) {
106
+ job.runningExecutions.set(event.executionId, event.nodeId);
107
+ }
108
+ else if ((event.type === "node_completed" || event.type === "node_failed") && event.executionId) {
109
+ job.runningExecutions.delete(event.executionId);
110
+ }
111
+ else if (event.type === "graph_completed" || event.type === "graph_failed") {
112
+ job.runningExecutions.clear();
113
+ }
103
114
  applyEvent(job.live, event);
104
115
  if ("nodeId" in event && Object.hasOwn(job.live.nodes, event.nodeId)) {
105
116
  // The panel keeps short previews; node retrieval needs the complete
@@ -161,6 +172,7 @@ export class BraidJobs {
161
172
  job.status = "failed";
162
173
  job.live.status = "failed";
163
174
  job.live.pausedExecutionIds = [];
175
+ job.runningExecutions.clear();
164
176
  job.error = error instanceof Error ? error.message : String(error);
165
177
  if (reports.length)
166
178
  job.usage = sumPiUsage(reports);
@@ -186,7 +198,7 @@ export class BraidJobs {
186
198
  const job = this.lookup(jobId);
187
199
  if (!job)
188
200
  return undefined;
189
- const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, ...snapshot } = job;
201
+ const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, ...snapshot } = job;
190
202
  const copy = structuredClone(snapshot);
191
203
  if (job.run)
192
204
  copy.execution = job.run.snapshot();
@@ -239,6 +251,20 @@ export class BraidJobs {
239
251
  }))
240
252
  .reverse();
241
253
  }
254
+ /** Editor progress without copying outputs, transcripts, or execution snapshots. */
255
+ progress(jobId) {
256
+ const job = this.lookup(jobId);
257
+ if (!job)
258
+ return undefined;
259
+ const nodes = Object.values(job.live.nodes);
260
+ return {
261
+ total: nodes.length,
262
+ done: nodes.filter(node => node.status === "completed" || node.status === "failed" || node.status === "skipped").length,
263
+ failed: nodes.filter(node => node.status === "failed").length,
264
+ running: [...new Set(job.runningExecutions.values())],
265
+ paused: job.live.pausedExecutionIds?.length ?? 0,
266
+ };
267
+ }
242
268
  cancel(jobId) {
243
269
  const job = this.lookup(jobId);
244
270
  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
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@chrok/pi-braid",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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.1"
9
+ "@chrok/braid": "0.3.2"
10
10
  },
11
11
  "peerDependencies": {
12
12
  "@earendil-works/pi-ai": "*",