@chrok/pi-braid 0.1.0

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.
@@ -0,0 +1,417 @@
1
+ import { render as renderMermaid } from "grok-mermaid";
2
+ import { Text, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
3
+ export const MAX_VISIBLE_EVENTS = 80;
4
+ export const MAX_VISIBLE_NODES = 80;
5
+ class FixedLines {
6
+ lines;
7
+ chartStart;
8
+ chartLength;
9
+ constructor(lines, chartStart = lines.length, chartLength = 0) {
10
+ this.lines = lines;
11
+ this.chartStart = chartStart;
12
+ this.chartLength = chartLength;
13
+ }
14
+ render(width) {
15
+ const chart = this.lines.slice(this.chartStart, this.chartStart + this.chartLength);
16
+ if (chart.length === 0)
17
+ 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
+ `[Flowchart needs ${chartWidth} columns; terminal width is ${width}. Expand your terminal to see it.]`,
23
+ ];
24
+ const output = [];
25
+ let chartInserted = false;
26
+ for (let index = 0; index < this.lines.length; index++) {
27
+ if (index === this.chartStart) {
28
+ output.push(...chartOutput);
29
+ chartInserted = true;
30
+ }
31
+ if (index < this.chartStart ||
32
+ index >= this.chartStart + this.chartLength) {
33
+ output.push(truncateToWidth(this.lines[index], width, ""));
34
+ }
35
+ }
36
+ if (!chartInserted)
37
+ output.push(...chartOutput);
38
+ return output;
39
+ }
40
+ invalidate() { }
41
+ }
42
+ function record(value) {
43
+ return value !== null && typeof value === "object" && !Array.isArray(value);
44
+ }
45
+ function compact(value, max = 90) {
46
+ if (typeof value !== "string")
47
+ return "…";
48
+ // Node IDs and model output are untrusted terminal content, not ANSI markup.
49
+ const line = stripTerminalSequences(value)
50
+ .replace(/\p{Cc}|\s+/gu, " ")
51
+ .trim();
52
+ return line.length <= max ? line : `${line.slice(0, max - 1)}…`;
53
+ }
54
+ function graphParts(result) {
55
+ if ("nodeTypes" in result)
56
+ return result;
57
+ const nodes = Object.assign(Object.create(null), result.nodes);
58
+ const nodeTypes = Object.create(null);
59
+ const edges = [];
60
+ for (const event of result.events) {
61
+ if (event.type === "node_created") {
62
+ nodes[event.nodeId] ??= { id: event.nodeId, status: "pending" };
63
+ if (event.model !== undefined &&
64
+ nodes[event.nodeId].model === undefined) {
65
+ nodes[event.nodeId].model = event.model;
66
+ }
67
+ Object.defineProperty(nodeTypes, event.nodeId, {
68
+ value: event.nodeType,
69
+ enumerable: true,
70
+ configurable: true,
71
+ });
72
+ }
73
+ else if (event.type === "edge_created") {
74
+ edges.push({
75
+ from: event.from,
76
+ to: event.to,
77
+ ...(event.choice ? { choice: event.choice } : {}),
78
+ });
79
+ }
80
+ }
81
+ const progress = "progress" in result &&
82
+ result.progress !== undefined &&
83
+ typeof result.progress === "object"
84
+ ? result.progress
85
+ : Object.create(null);
86
+ return { nodes, nodeTypes, edges, progress };
87
+ }
88
+ function compactCount(value) {
89
+ if (value >= 1_000_000)
90
+ return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}M`;
91
+ if (value >= 1_000)
92
+ return `${Math.round(value / 1_000)}K`;
93
+ return String(value);
94
+ }
95
+ function elapsed(ms) {
96
+ if (ms === undefined)
97
+ return "—";
98
+ if (ms < 1000)
99
+ return `${(ms / 1000).toFixed(2)}s`;
100
+ if (ms < 60_000)
101
+ return `${Math.floor(ms / 1000)}s`;
102
+ return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`;
103
+ }
104
+ function nodeElapsed(node, now) {
105
+ if (node.latencyMs !== undefined)
106
+ return elapsed(node.latencyMs);
107
+ if (node.status === "running" && node.startedAt !== undefined) {
108
+ return elapsed(Math.max(0, now - node.startedAt));
109
+ }
110
+ return "—";
111
+ }
112
+ function mermaidLabelLines(id, result) {
113
+ const progress = "progress" in result ? result.progress?.[id] : undefined;
114
+ const node = result.nodes[id];
115
+ if (!node)
116
+ return [compact(id, 24), "pending"];
117
+ const icon = node.status === "running"
118
+ ? "▶ ACTIVE"
119
+ : {
120
+ completed: "✓",
121
+ failed: "✗",
122
+ skipped: "·",
123
+ runnable: "◇",
124
+ pending: "○",
125
+ }[node.status];
126
+ const status = node.status === "completed" ? "done" : node.status;
127
+ const decision = node.decision === undefined ? "" : ` → ${compact(node.decision, 16)}`;
128
+ const now = "observedAt" in result ? result.observedAt : Date.now();
129
+ const lines = [
130
+ `${icon} ${compact(id, 24)}`,
131
+ `${status}${decision} · ${nodeElapsed(node, now)}`,
132
+ ];
133
+ if (progress) {
134
+ const window = progress.contextWindow === undefined
135
+ ? "—"
136
+ : compactCount(progress.contextWindow);
137
+ const prefix = progress.contextSource === "estimate" ? "~" : "";
138
+ lines.push(`${prefix}${compactCount(progress.contextTokens)}/${window} · T${progress.toolCalls}`);
139
+ }
140
+ return lines;
141
+ }
142
+ function mermaidSource(result) {
143
+ const parts = graphParts(result);
144
+ const ids = Object.keys(parts.nodes);
145
+ const names = new Map(ids.map((id, index) => [id, `n${index}`]));
146
+ const escapeMermaidText = (value) => stripTerminalSequences(value)
147
+ .replace(/\p{Cc}/gu, "")
148
+ .replace(/&/g, "&amp;")
149
+ .replace(/"/g, "&quot;")
150
+ .replace(/\|/g, "&#124;")
151
+ .replace(/[[\]{}<>]/g, (character) => ({
152
+ "[": "&#91;",
153
+ "]": "&#93;",
154
+ "{": "&#123;",
155
+ "}": "&#125;",
156
+ "<": "&lt;",
157
+ ">": "&gt;",
158
+ })[character] ?? character);
159
+ const lines = ["flowchart TD"];
160
+ for (const id of ids) {
161
+ const shape = parts.nodeTypes[id] === "decision" ? "{" : "[";
162
+ const close = parts.nodeTypes[id] === "decision" ? "}" : "]";
163
+ lines.push(` ${names.get(id)}${shape}"${mermaidLabelLines(id, result).map(escapeMermaidText).join("<br/>")}"${close}`);
164
+ }
165
+ for (const edge of parts.edges) {
166
+ lines.push(` ${names.get(edge.from)} -->${edge.choice ? `|${escapeMermaidText(edge.choice)}|` : ""} ${names.get(edge.to)}`);
167
+ }
168
+ return lines.join("\n");
169
+ }
170
+ function mermaidLines(result, theme) {
171
+ const art = renderMermaid(mermaidSource(result));
172
+ if (!art)
173
+ return [];
174
+ return art.styled.map((row) => row
175
+ .map((span) => {
176
+ switch (span.cls) {
177
+ case "border":
178
+ return theme.fg("borderMuted", span.text);
179
+ case "text":
180
+ return span.text.includes("▶ ACTIVE")
181
+ ? theme.bg("selectedBg", theme.bold(theme.fg("accent", span.text)))
182
+ : theme.fg("text", span.text);
183
+ case "edge":
184
+ return theme.fg("accent", span.text);
185
+ case "edgeLabel":
186
+ return theme.fg("muted", span.text);
187
+ case "title":
188
+ return theme.fg("accent", theme.bold(span.text));
189
+ default:
190
+ return span.text;
191
+ }
192
+ })
193
+ .join(""));
194
+ }
195
+ export function renderGraphCall(args, theme) {
196
+ // Pi renders partially streamed tool arguments, including incomplete array items.
197
+ const partial = record(args) ? args : {};
198
+ const nodes = Array.isArray(partial.nodes)
199
+ ? partial.nodes.filter(record)
200
+ : [];
201
+ const edges = Array.isArray(partial.edges)
202
+ ? partial.edges.filter(record)
203
+ : [];
204
+ const options = record(partial.options) ? partial.options : {};
205
+ const decisions = nodes.filter((node) => node.type === "decision").length;
206
+ const route = edges
207
+ .slice(0, 5)
208
+ .map((edge) => `${compact(edge.from, 30)}${edge.choice ? ` -${compact(edge.choice, 20)}->` : " →"} ${compact(edge.to, 30)}`)
209
+ .join(" ");
210
+ const nodeLabels = nodes
211
+ .slice(0, 8)
212
+ .map((node) => `${node.type === "decision" ? "◇" : "○"} ${compact(node.id, 30)}`);
213
+ return new FixedLines([
214
+ theme.fg("toolTitle", "◆ Braid"),
215
+ theme.fg("muted", `${nodes.length} nodes (${decisions} decision) · ${edges.length} edges · max ${options.maxConcurrency ?? 4}`),
216
+ theme.fg("dim", `goal: ${compact(partial.goal)}`),
217
+ theme.fg("accent", `nodes: ${nodeLabels.join(" ")}${nodes.length > 8 ? ` +${nodes.length - 8} more` : ""}`),
218
+ theme.fg("dim", route
219
+ ? `graph: ${route}${edges.length > 5 ? ` +${edges.length - 5} more` : ""}`
220
+ : "graph: no edges yet"),
221
+ ]);
222
+ }
223
+ function eventText(event) {
224
+ switch (event.type) {
225
+ case "graph_created":
226
+ return `graph created · ${event.nodeCount} nodes · ${event.edgeCount} edges`;
227
+ case "node_created":
228
+ return `node created · ${compact(event.nodeId, 40)} (${event.nodeType})${event.model ? ` · ${compact(event.model, 50)}` : ""}`;
229
+ case "edge_created":
230
+ return `edge created · ${compact(event.from, 40)}${event.choice ? ` -${compact(event.choice, 30)}->` : " →"} ${compact(event.to, 40)}`;
231
+ case "workspace_updated":
232
+ return `workspace · ${compact(event.workspace.nodeId, 40)} · ${event.workspace.state}`;
233
+ case "node_runnable":
234
+ return `runnable · ${compact(event.nodeId, 40)}`;
235
+ case "handoff":
236
+ return `handoff · ${compact(event.from, 40)} → ${compact(event.to, 40)}: ${compact(event.output, 64)}`;
237
+ case "node_started":
238
+ return `started · ${compact(event.nodeId, 40)}${event.model ? ` · ${compact(event.model, 50)}` : ""}`;
239
+ case "node_completed":
240
+ return `completed · ${compact(event.nodeId, 40)}${event.decision ? ` → ${compact(event.decision, 30)}` : ""}: ${compact(event.output, 64)}`;
241
+ case "node_skipped":
242
+ return `skipped · ${compact(event.nodeId, 40)} (${event.reason})`;
243
+ case "node_failed":
244
+ return `failed · ${compact(event.nodeId, 40)} (${event.error.code}): ${compact(event.error.message, 64)}`;
245
+ case "graph_completed":
246
+ return `graph completed · terminals: ${compact(event.terminalNodeIds.join(", "), 80) || "none"}`;
247
+ case "graph_failed":
248
+ return `graph failed · ${event.error.code}: ${compact(event.error.message, 80)}`;
249
+ }
250
+ }
251
+ function renderEventLog(events, expanded, theme) {
252
+ if (!events.length)
253
+ return [];
254
+ const visible = events.slice(-(expanded ? MAX_VISIBLE_EVENTS : 8));
255
+ const total = events.at(-1).sequence;
256
+ const lines = [theme.fg("dim", `execution log · ${total} events:`)];
257
+ if (visible[0].sequence > 1) {
258
+ lines.push(theme.fg("dim", ` … ${visible[0].sequence - 1} earlier events${expanded ? " (see full result)" : " (expand for more)"}`));
259
+ }
260
+ for (const event of visible) {
261
+ const color = event.type.endsWith("failed")
262
+ ? "error"
263
+ : event.type.endsWith("completed")
264
+ ? "success"
265
+ : event.type === "node_started"
266
+ ? "accent"
267
+ : "dim";
268
+ lines.push(theme.fg(color, ` ${String(event.sequence).padStart(3, "0")} ${eventText(event)}`));
269
+ }
270
+ return lines;
271
+ }
272
+ export function createLiveState() {
273
+ return {
274
+ status: "running",
275
+ nodes: Object.create(null),
276
+ nodeTypes: Object.create(null),
277
+ edges: [],
278
+ progress: Object.create(null),
279
+ events: [],
280
+ latencyMs: 0,
281
+ observedAt: Date.now(),
282
+ };
283
+ }
284
+ export function applyEvent(state, event) {
285
+ // Keep per-update payloads bounded, without discarding any events from the core result.
286
+ const preview = { ...event };
287
+ if ("output" in preview && preview.output !== undefined)
288
+ preview.output = compact(preview.output, 180);
289
+ if ("error" in preview)
290
+ preview.error = {
291
+ ...preview.error,
292
+ message: compact(preview.error.message, 180),
293
+ };
294
+ state.events.push(preview);
295
+ if (event.type === "node_created")
296
+ Object.defineProperty(state.nodeTypes, event.nodeId, {
297
+ value: event.nodeType,
298
+ enumerable: true,
299
+ configurable: true,
300
+ });
301
+ if (event.type === "edge_created")
302
+ state.edges.push({
303
+ from: event.from,
304
+ to: event.to,
305
+ ...(event.choice ? { choice: event.choice } : {}),
306
+ });
307
+ if (state.events.length > MAX_VISIBLE_EVENTS)
308
+ state.events.shift();
309
+ if (event.type === "node_created") {
310
+ Object.defineProperty(state.nodes, event.nodeId, {
311
+ value: {
312
+ id: event.nodeId,
313
+ status: "pending",
314
+ ...(event.model ? { model: event.model } : {}),
315
+ },
316
+ enumerable: true,
317
+ configurable: true,
318
+ writable: true,
319
+ });
320
+ return;
321
+ }
322
+ const node = "nodeId" in event && Object.hasOwn(state.nodes, event.nodeId)
323
+ ? state.nodes[event.nodeId]
324
+ : undefined;
325
+ if (!node)
326
+ return;
327
+ switch (event.type) {
328
+ case "node_runnable":
329
+ node.status = "runnable";
330
+ break;
331
+ case "node_started":
332
+ node.status = "running";
333
+ node.startedAt = event.timestamp;
334
+ break;
335
+ case "node_completed":
336
+ case "node_failed":
337
+ node.status = event.type === "node_completed" ? "completed" : "failed";
338
+ node.finishedAt = event.timestamp;
339
+ node.latencyMs = event.latencyMs;
340
+ if (event.output !== undefined)
341
+ node.output = compact(event.output, 180);
342
+ if (event.decision !== undefined)
343
+ node.decision = event.decision;
344
+ if (event.model !== undefined)
345
+ node.model = event.model;
346
+ if (event.usage !== undefined)
347
+ node.usage = { ...event.usage };
348
+ if (event.type === "node_failed")
349
+ node.error = {
350
+ ...event.error,
351
+ message: compact(event.error.message, 180),
352
+ };
353
+ break;
354
+ case "node_skipped":
355
+ node.status = "skipped";
356
+ node.skipReason = event.reason;
357
+ node.finishedAt = event.timestamp;
358
+ break;
359
+ }
360
+ }
361
+ export function applyProgress(state, progress) {
362
+ Object.defineProperty(state.progress, progress.nodeId, {
363
+ value: progress,
364
+ enumerable: true,
365
+ configurable: true,
366
+ writable: true,
367
+ });
368
+ }
369
+ export function renderGraphResult(result, expanded, isPartial, theme, fallback = "", isError = false) {
370
+ if (isError || !result?.nodes) {
371
+ const message = fallback ||
372
+ (isPartial ? "Braid is running…" : "Braid returned no execution details");
373
+ return new Text(theme.fg(isError ? "error" : "warning", compact(message, 500)), 0, 0);
374
+ }
375
+ const nodes = Object.values(result.nodes);
376
+ const completed = nodes.filter((node) => node.status === "completed").length;
377
+ const skipped = nodes.filter((node) => node.status === "skipped").length;
378
+ const failed = nodes.filter((node) => node.status === "failed").length;
379
+ const active = nodes.filter((node) => node.status === "running").length;
380
+ const pending = nodes.filter((node) => node.status === "pending" || node.status === "runnable").length;
381
+ const elapsedMs = "metadata" in result ? result.metadata.latencyMs : result.latencyMs;
382
+ const title = result.status === "running"
383
+ ? theme.fg("warning", "⟳ Braid executing")
384
+ : result.status === "completed"
385
+ ? theme.fg("success", "✓ Braid completed")
386
+ : theme.fg("error", "✗ Braid failed");
387
+ const lines = [
388
+ title,
389
+ theme.fg("muted", `${completed}/${nodes.length} completed · ${result.status === "running" ? `${active} active · ${pending} pending · ` : ""}${skipped} skipped · ${failed} failed · ${elapsed(elapsedMs)}`),
390
+ ];
391
+ if ("terminalOutputs" in result) {
392
+ const terminals = Object.keys(result.terminalOutputs);
393
+ if (terminals.length)
394
+ lines.push(theme.fg("accent", `terminals: ${compact(terminals.join(", "), 120)}`));
395
+ if (result.error)
396
+ lines.push(theme.fg("error", `${result.error.code}: ${compact(result.error.message, 120)}`));
397
+ }
398
+ const chartStart = lines.length;
399
+ const chart = mermaidLines(result, theme);
400
+ lines.push(...chart);
401
+ const workspaces = Object.values(result.workspaces ?? {}).filter(workspace => workspace.worktreeRoot);
402
+ if (workspaces.length) {
403
+ const active = workspaces.filter(workspace => ["preparing", "ready", "failed"].includes(workspace.state)).length;
404
+ lines.push(theme.fg("accent", `workspaces: ${active} active · ${workspaces.length - active} cleaned${expanded ? "" : " (expand for recovery refs)"}`));
405
+ if (expanded) {
406
+ for (const workspace of workspaces.slice(0, MAX_VISIBLE_NODES)) {
407
+ const active = ["preparing", "ready", "failed"].includes(workspace.state);
408
+ lines.push(theme.fg("dim", ` ${compact(workspace.nodeId, 40)} · ${workspace.state}: ${compact(active ? workspace.worktreeRoot : workspace.checkpointRef ?? workspace.reason, 240)}`));
409
+ }
410
+ }
411
+ }
412
+ if (expanded)
413
+ lines.push(...renderEventLog(result.events ?? [], true, theme));
414
+ if ("fullOutputPath" in result && result.fullOutputPath)
415
+ lines.push(theme.fg("dim", `full result/log: ${compact(result.fullOutputPath, 240)}`));
416
+ return new FixedLines(lines, chartStart, chart.length);
417
+ }
@@ -0,0 +1,232 @@
1
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
2
+ import { defineTool, truncateHead, } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { BraidJobs } from "./jobs.js";
5
+ import { registerBraidCommand } from "./command.js";
6
+ import { renderGraphCall, renderGraphResult } from "./display.js";
7
+ const text = () => Type.String({ minLength: 1 });
8
+ const timeout = () => Type.Optional(Type.Number({
9
+ exclusiveMinimum: 0,
10
+ maximum: 2_147_483_647,
11
+ description: "Timeout in milliseconds; omit for no time limit",
12
+ }));
13
+ const toolBudget = (unit) => Type.Optional(Type.Integer({
14
+ minimum: 1,
15
+ maximum: Number.MAX_SAFE_INTEGER,
16
+ description: `Maximum tool ${unit} per node, including decide and rejected requests; omit for no limit`,
17
+ }));
18
+ const braidParameters = Type.Object({
19
+ goal: text(),
20
+ // A flat object avoids provider-specific discriminated-union schema problems.
21
+ nodes: Type.Array(Type.Object({
22
+ type: StringEnum(["execute", "decision", "merge"]),
23
+ id: text(),
24
+ prompt: Type.Optional(text()),
25
+ model: Type.Optional(Type.String({
26
+ description: "Exact provider/modelId; default is the current Pi model",
27
+ })),
28
+ choices: Type.Optional(Type.Array(text(), {
29
+ minItems: 1,
30
+ description: "Required on decision nodes; forbidden on execute nodes",
31
+ })),
32
+ }, { additionalProperties: false }), { minItems: 1 }),
33
+ edges: Type.Array(Type.Object({
34
+ from: text(),
35
+ to: text(),
36
+ choice: Type.Optional(text()),
37
+ }, { additionalProperties: false })),
38
+ options: Type.Optional(Type.Object({
39
+ maxConcurrency: Type.Optional(Type.Integer({ minimum: 1 })),
40
+ nodeTimeoutMs: timeout(),
41
+ graphTimeoutMs: timeout(),
42
+ maxToolRounds: toolBudget("rounds"),
43
+ maxToolCalls: toolBudget("calls"),
44
+ }, { additionalProperties: false })),
45
+ }, { additionalProperties: false });
46
+ const BRAID_FILESYSTEM_GUIDANCE = "In a Git repository, execute and decision nodes get individual writable worktrees with read, ls, write, edit, and Git inspection. Search tools grep/find are exposed only when their local rg/fd dependencies are available. " +
47
+ "Worktrees include tracked changes and non-ignored untracked files. Merge nodes operate in the source checkout and decide whether to merge, cherry-pick, apply, or discard predecessor changes; core never makes that choice. " +
48
+ "Merge agents must call finish_merge for every source; core checkpoints changes and removes processed worktrees. Core appends a final merge agent for remaining worktrees. Failed predecessors pass their errors and partial work along unconditional edges. " +
49
+ "Outside Git, nodes have read and ls, plus grep/find when their local dependencies are available. Shell commands and tests remain unavailable in all nodes. " +
50
+ "Inspect braid_status for integration outcomes and recovery checkpoint refs, then run tests in the parent. Avoid concurrent parent edits while a merge agent owns the source checkout.";
51
+ const BRAID_USAGE_GUIDANCE = [
52
+ "Braid is a proactive execution primitive, not only a user-requested command.",
53
+ "Selection rule: for a code review, bug investigation, design comparison, test-planning request, or change spanning multiple files, call braid FIRST when two or more concerns can be analyzed independently. Do this without waiting for the user to say Braid; do not read everything in the parent and then decide whether to delegate.",
54
+ BRAID_FILESYSTEM_GUIDANCE,
55
+ "When Braid fits, construct and submit the complete DAG in one call: use parallel execute nodes for independent concerns and a final execute node to synthesize their outputs. The tool returns a jobId immediately. Continue independent work or finish your turn while it runs; do not poll repeatedly. A completion reminder will resume you. Use braid_status with the jobId to retrieve terminal outputs before relying on them.",
56
+ "Do not use braid for a simple one-step answer, a trivial direct edit, shell work, or when decomposition adds no value. The parent reviews results, runs tests, and executes shell commands after Braid completes.",
57
+ ].join("\n");
58
+ export function createBraidTools(jobs) {
59
+ const braidTool = defineTool({
60
+ name: "braid",
61
+ label: "Braid",
62
+ description: "Use this tool FIRST for nontrivial engineering work: code reviews, bug investigations, design comparisons, test planning, and changes spanning multiple files. " +
63
+ "It starts a background job and immediately returns jobId for a complete DAG of isolated LLM invocations with parallel branches and joins; the user does not need to mention Braid. " +
64
+ "Use execute, decision, or merge nodes. Decision nodes must declare choices and call decide; matching choice edges activate together. Merge nodes accept multiple predecessors and an optional prompt. " +
65
+ "Unlabelled edges are unconditional. Joins wait for all possible predecessor paths to resolve. " +
66
+ "Nodes see only the goal, their prompt, labelled direct-predecessor outputs, and their filesystem capabilities: " +
67
+ "no parent history, shell, tests, or recursive Braid calls. " +
68
+ BRAID_FILESYSTEM_GUIDANCE + " " +
69
+ "Do not use it for a simple one-step answer or trivial direct edit. " +
70
+ "Use braid_status(jobId) for progress and results, or braid_cancel(jobId) to stop it. A completion reminder resumes the agent if idle; do independent work or end your turn instead of polling. Humans can open /braid for the live flow panel. " +
71
+ "Read result.status: failed graphs can still return successful terminal outputs.",
72
+ promptSnippet: "Use FIRST for nontrivial code review/debug/design work; parallelize independent analysis and synthesize",
73
+ promptGuidelines: [
74
+ "Call braid before direct repository inspection when a code task has two or more separable review, debugging, design, or test-planning concerns; the Braid nodes can inspect the project and edit isolated Git worktrees.",
75
+ "Use parallel execute nodes for independent concerns and a final synthesis node. The user does not need to mention Braid or design the graph.",
76
+ "Do not use braid for simple one-step answers or trivial direct edits. Keep shell commands and test execution in the parent; use merge nodes for integration.",
77
+ BRAID_FILESYSTEM_GUIDANCE,
78
+ "Decision nodes additionally receive decide. Nodes cannot call recursive Braid.",
79
+ "Tool and time budgets are unlimited by default. Set maxToolRounds, maxToolCalls, nodeTimeoutMs, or graphTimeoutMs in options to impose hard limits; nodes receive system reminders of their remaining budgets before each model call.",
80
+ ],
81
+ parameters: braidParameters,
82
+ renderCall(args, theme) {
83
+ return renderGraphCall(args, theme);
84
+ },
85
+ renderResult(result, _options, theme) {
86
+ const job = result.details;
87
+ return new Text(theme.fg(job ? "accent" : "error", job
88
+ ? `Braid background job ${job.jobId} · ${job.status} · /braid to view`
89
+ : result.content
90
+ .filter((item) => item.type === "text")
91
+ .map((item) => item.text)
92
+ .join("\n")), 0, 0);
93
+ },
94
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
95
+ signal?.throwIfAborted();
96
+ try {
97
+ const job = jobs.start({
98
+ goal: params.goal,
99
+ nodes: params.nodes,
100
+ edges: params.edges,
101
+ }, params.options ?? {}, ctx);
102
+ return {
103
+ content: [
104
+ {
105
+ type: "text",
106
+ text: JSON.stringify({
107
+ jobId: job.handle,
108
+ canonicalJobId: job.jobId,
109
+ status: job.status,
110
+ message: "Running in background. Use braid_status to retrieve progress/results. A completion reminder will resume you; do not poll repeatedly.",
111
+ }),
112
+ },
113
+ ],
114
+ details: job,
115
+ };
116
+ }
117
+ catch (error) {
118
+ throw new Error(`Braid submission failed: ${error instanceof Error ? error.message : String(error)}`);
119
+ }
120
+ },
121
+ });
122
+ const statusParameters = Type.Object({ jobId: Type.Optional(text()) }, { additionalProperties: false });
123
+ const statusTool = defineTool({
124
+ name: "braid_status",
125
+ label: "Braid status",
126
+ 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. Prefer the short handle from submission/reminders. Omit jobId to list jobs in this session. Completion reminders arrive automatically; avoid repeated polling.",
127
+ parameters: statusParameters,
128
+ renderResult(result, options, theme) {
129
+ const job = result.details;
130
+ const fallback = result.content
131
+ .filter((item) => item.type === "text")
132
+ .map((item) => item.text)
133
+ .join("\n");
134
+ if (!job)
135
+ return new Text(fallback, 0, 0);
136
+ if (job.error)
137
+ return new Text(theme.fg("error", `Braid ${job.status}: ${job.error}`), 0, 0);
138
+ return renderGraphResult({
139
+ ...(job.result ?? job.live),
140
+ progress: job.live.progress,
141
+ ...(job.workspaces ? { workspaces: job.workspaces } : {}),
142
+ ...(job.fullOutputPath ? { fullOutputPath: job.fullOutputPath } : {}),
143
+ }, options.expanded, options.isPartial, theme, fallback);
144
+ },
145
+ async execute(_id, params) {
146
+ if (!params.jobId)
147
+ return {
148
+ content: [{ type: "text", text: JSON.stringify(jobs.list()) }],
149
+ details: undefined,
150
+ };
151
+ const job = jobs.get(params.jobId);
152
+ if (!job)
153
+ throw jobs.unknownJob(params.jobId);
154
+ const preview = truncateHead(JSON.stringify(job, null, 2));
155
+ const suffix = preview.truncated
156
+ ? `\n[Preview truncated. ${job.fullOutputPath ? `Full result/log: ${job.fullOutputPath}` : "Full results will be available when the job finishes."}]`
157
+ : "";
158
+ const usage = jobs.claimUsage(job.jobId);
159
+ return {
160
+ content: [{ type: "text", text: preview.content + suffix }],
161
+ details: job,
162
+ ...(usage ? { usage } : {}),
163
+ };
164
+ },
165
+ });
166
+ const cancelParameters = Type.Object({ jobId: text() }, { additionalProperties: false });
167
+ const cancelTool = defineTool({
168
+ name: "braid_cancel",
169
+ label: "Cancel Braid",
170
+ description: "Cancel a background Braid job by jobId. Stopping the foreground response does not cancel background jobs.",
171
+ parameters: cancelParameters,
172
+ async execute(_id, params) {
173
+ const cancelled = jobs.cancel(params.jobId);
174
+ return {
175
+ content: [
176
+ {
177
+ type: "text",
178
+ text: cancelled
179
+ ? "Cancellation requested."
180
+ : "Job has already finished.",
181
+ },
182
+ ],
183
+ details: undefined,
184
+ };
185
+ },
186
+ });
187
+ return { braidTool, statusTool, cancelTool };
188
+ }
189
+ export default function braidExtension(pi) {
190
+ const pending = new Map();
191
+ const remind = (jobId, status) => {
192
+ const handle = jobs.get(jobId)?.handle ?? jobId;
193
+ pi.sendMessage({
194
+ customType: "braid-completed",
195
+ display: true,
196
+ content: `[system-reminder] Braid job ${handle} finished with status ${status}. Retrieve its results with braid_status({"jobId":"${handle}"}) and continue the original task. Failed or cancelled jobs may contain successful partial outputs. [/system-reminder]`,
197
+ details: { jobId, handle, status },
198
+ }, { triggerTurn: true, deliverAs: "followUp" });
199
+ };
200
+ const jobs = new BraidJobs((job) => {
201
+ pending.set(job.jobId, job.status);
202
+ remind(job.jobId, job.status);
203
+ });
204
+ // Foreground cancellation can discard queued follow-ups. Retry only reminders
205
+ // that never entered context, once Pi has settled and emptied its queues.
206
+ pi.on("message_start", (event) => {
207
+ const message = event.message;
208
+ if (message.role === "custom" && message.customType === "braid-completed") {
209
+ const details = message.details;
210
+ if (details?.jobId)
211
+ pending.delete(details.jobId);
212
+ }
213
+ });
214
+ pi.on("agent_settled", (_event, ctx) => {
215
+ if (ctx.isIdle() && !ctx.hasPendingMessages()) {
216
+ for (const [jobId, status] of pending)
217
+ remind(jobId, status);
218
+ }
219
+ });
220
+ const { braidTool, statusTool, cancelTool } = createBraidTools(jobs);
221
+ registerBraidCommand(pi, jobs);
222
+ pi.registerTool(braidTool);
223
+ pi.registerTool(statusTool);
224
+ pi.registerTool(cancelTool);
225
+ pi.on("session_shutdown", () => {
226
+ pending.clear();
227
+ jobs.dispose();
228
+ });
229
+ pi.on("before_agent_start", (event) => ({
230
+ 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.`,
231
+ }));
232
+ }