@chrok/pi-braid 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -5,11 +5,15 @@
5
5
  This optional Pi extension runs Braid agent graphs as background jobs with
6
6
  bounded loops, live updates, pause/resume, and a live flow panel.
7
7
 
8
- **0.2 API:** The npm badge shows the published version; see
8
+ **0.3 API:** The npm badge shows the published version; see
9
9
  [GitHub releases](https://github.com/Epsirom/braid/releases) for release notes.
10
- Review the [migration guide](https://github.com/Epsirom/braid/blob/v0.2.0/docs/compatibility.md#migrating-from-01-to-02)
11
- before upgrading an existing graph. For the previous API, use the
12
- [0.1.3 guide](https://github.com/Epsirom/braid/blob/v0.1.3/integrations/pi/README.md).
10
+ Review the [0.2 → 0.3 migration guide](https://github.com/Epsirom/braid/blob/v0.3.0/docs/compatibility.md#migrating-from-02-to-03)
11
+ before upgrading: writable nodes now have shell tools, checkpoints exclude
12
+ untracked ignored outputs, and reminders can interrupt work between model steps.
13
+ Users coming from 0.1 also need the
14
+ [0.1 → 0.2 guide](https://github.com/Epsirom/braid/blob/v0.3.0/docs/compatibility.md#migrating-from-01-to-02).
15
+ For the previous release, use the
16
+ [0.2.1 guide](https://github.com/Epsirom/braid/blob/v0.2.1/integrations/pi/README.md).
13
17
 
14
18
  It registers:
15
19
 
@@ -26,11 +30,19 @@ Submission and completion reminders use short session handles such as `job-1`.
26
30
  Status, cancellation and the panel accept either that exact handle or the original
27
31
  UUID. Unknown IDs report available handles; IDs are never guessed or fuzzy-matched.
28
32
 
33
+ Invalid submissions show the validation error directly in the tool result,
34
+ including failures before a background job is created. Correct the indicated
35
+ field or graph relationship and resubmit. `braid_status` lookup failures also
36
+ display their error text.
37
+
29
38
  The parent can continue independent work or finish its response while a job runs.
30
39
  On completion, failure, or cancellation, the extension sends a custom
31
40
  `system-reminder` containing the job ID and a request to retrieve its results.
32
- Pi queues it as a follow-up during streaming; when idle, it starts a new agent
33
- turn automatically. The agent should wait for this reminder rather than poll.
41
+ Pi queues it as steering during streaming: it enters context after the current
42
+ assistant response and its entire tool batch, before the next model step. It
43
+ does not wait for the whole foreground task to finish or skip remaining tools.
44
+ When idle, it starts a new agent turn automatically. The agent should wait for
45
+ this reminder rather than poll.
34
46
  Stopping the foreground response does not stop background jobs.
35
47
 
36
48
  Jobs live in memory for the current Pi session. Quitting, reloading extensions,
@@ -38,6 +50,38 @@ or switching/forking sessions aborts outstanding work and suppresses its
38
50
  reminders. Job IDs cannot be retrieved after that lifecycle ends. They are not
39
51
  persistent processes outside Pi.
40
52
 
53
+ ## Graph definitions
54
+
55
+ Each node has a unique `id` and one of these four shapes:
56
+
57
+ | `type` | `prompt` | `choices` | `workspace` |
58
+ | --- | --- | --- | --- |
59
+ | `execute` | Required | Omit | Optional: `read-only` or `worktree` |
60
+ | `decision` | Required | Required, non-empty, distinct strings | Optional: `read-only` or `worktree` |
61
+ | `merge` | Optional | Omit | Omit; combines changes in an isolated worktree |
62
+ | `integrate` | Optional | Omit | Omit; applies changes to the invoking checkout |
63
+
64
+ All four types accept `model`, `notifyOnCompletion`, `pauseAfter`, and
65
+ `requireSuccess`. Omit optional fields when unused. Execute/decision nodes
66
+ default to writable worktrees in Git; outside Git, all workers are read-only.
67
+ The model-facing schema exposes these fields in one object with a `type` enum;
68
+ core validation enforces the type-specific requirements before a submission or
69
+ update takes effect. Submission/update replies echo the accepted node types,
70
+ model overrides, policies, and edges without repeating prompts. If a definition
71
+ problem repeats, inspect/report the mismatch instead of launching more probe jobs.
72
+
73
+ Edges use exact node IDs in `from` and `to`. `choice` is an exact label declared
74
+ by the source decision; omitting it makes the edge unconditional. Pass `edges: []`
75
+ for independent roots. Historical `executionId` pins are available only in
76
+ `braid_update`, after the source execution exists.
77
+
78
+ A retry cycle needs a `loops` definition and exactly one feedback edge from a
79
+ decision back to the loop entry, carrying both `choice` and `feedback: "<loop-id>"`.
80
+ The decision needs another choice to exit. `maxIterations` counts the first round
81
+ as well as retries. The body must be acyclic after removing the feedback edge;
82
+ external edges enter only at the entry and leave only through that decision.
83
+ Loops cannot nest or overlap. See the [loop example](../../docs/execution-control.md#structured-loops).
84
+
41
85
  ## Shared prompts
42
86
 
43
87
  The `braid` tool accepts a `promptTemplates` object alongside `goal`, `nodes`,
@@ -85,11 +129,33 @@ expectedRevision, executionIds})`. Definitions can be changed while executions
85
129
  are running or waiting, including inside a loop. Existing instances keep their
86
130
  captured prompt, inputs, and policy; completion routes through the latest graph.
87
131
  Rejected revisions/changes have no effects. Finalized jobs cannot be reopened.
132
+ Include new nodes and their dependencies/loops in the same update. A new node
133
+ without incoming edges is a runnable root; another node's `pauseAfter` does not
134
+ hold it. After a rejected update, retry the complete corrected patch, including
135
+ edges, loops, and resume IDs, instead of staging disconnected nodes separately.
136
+ Pause reminders describe the event when it occurred: check current status and
137
+ paused IDs before attempting an update/resume, since the job can time out or be
138
+ cancelled before the parent handles the reminder.
139
+
140
+ Focused `braid_status` reads also include the current control fields; the
141
+ returned `node.revision` is the revision captured when that invocation started.
142
+ Use `execution.revision` for updates even when inspecting an older invocation.
143
+ After cancellation or finalization there are no resumable paused executions;
144
+ the event log still records where pauses occurred.
145
+
146
+ `upsertNodes` replaces whole node definitions, so include all required fields.
147
+ `promptTemplates` and `loops` replace their entire map/list when supplied;
148
+ omitting them preserves the current definitions. `removeEdges` matches exact
149
+ identities, including any `choice`, `feedback`, or `executionId`: omitted fields
150
+ are not wildcards. `resume` and `braid_resume.executionIds` take paused execution
151
+ IDs, not node IDs.
88
152
 
89
153
  `requireSuccess` defaults to false. Optional failures retain artifacts and allow
90
154
  unconditional recovery; required failures cancel siblings and fail the job after
91
155
  cleanup. All loops declare a finite `maxIterations`; total `maxExecutions`
92
156
  defaults to 1000 and spans updates. Deadlines keep running through pauses.
157
+ The graph timeout includes time waiting for the parent to inspect and update a
158
+ paused graph, and resuming does not extend it. Reserve time for integration.
93
159
 
94
160
  See [execution control](../../docs/execution-control.md) for loop schemas, exact
95
161
  update semantics, historical dependencies, and workspace lineage. There is no
@@ -109,6 +175,8 @@ The panel renders a Mermaid flowchart, node states, elapsed times, context-token
109
175
  estimates or provider-reported usage, context-window sizes, filesystem tool-call
110
176
  counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status
111
177
  tool also renders a flowchart; expand its result to see more log events.
178
+ When selecting `nodeId` or `executionId`, it instead shows that invocation's
179
+ output/error, with the full text available on expansion.
112
180
 
113
181
  `/braid` now opens this panel; it no longer arms the next prompt. To request
114
182
  Braid explicitly, ask the agent to analyze the task using Braid.
@@ -119,6 +187,11 @@ model lookup, credentials/OAuth, provider transport, filesystem tool execution,
119
187
  and token/cost accounting. The first
120
188
  whole-job `braid_status` retrieval of a finished job reports its accumulated Pi usage;
121
189
  subsequent retrievals do not count the same usage again.
190
+ Use the status response's `usage` for Pi totals, including completed provider
191
+ rounds from nodes that later fail or time out. Saved final result files expose
192
+ the same accounting as `piUsage`; core `metadata.usage` only includes usage
193
+ returned by runners. Large running status reads save a complete snapshot too,
194
+ so truncation never requires waiting for job completion to inspect the graph.
122
195
 
123
196
  ## When Pi will use Braid
124
197
 
@@ -130,8 +203,8 @@ an explicit per-turn planning policy to Pi's system prompt and tool metadata:
130
203
  changes spanning multiple files, call Braid first when two or more concerns
131
204
  can be handled independently; use `workspace: "read-only"` for analysis,
132
205
  review, routing, and synthesis, and worktrees for implementation;
133
- - do not use Braid for simple one-step answers, trivial direct edits, or shell
134
- work; keep tests and shell commands in the parent agent;
206
+ - do not use Braid for simple one-step answers, trivial direct edits, or a single
207
+ shell command; writable nodes can implement, test, and fix their own work;
135
208
  - the user does not need to say “Braid” or design the graph;
136
209
  - when Braid fits, the model should submit a graph and refine it with live updates when needed, continue independent work, and retrieve the terminal outputs after
137
210
  the completion reminder.
@@ -142,16 +215,18 @@ or strengthen the project/system prompt for that model. The adapter explicitly
142
215
  asks the model to make the delegation choice before directly inspecting the
143
216
  repository. Do not add a generic `always call braid` rule: that would waste
144
217
  model calls and bypass direct tools.
145
- Merge nodes combine snapshots; integrate nodes apply selected changes to the caller; the parent reviews results and runs tests.
218
+ Merge nodes combine and validate snapshots; integrate nodes apply selected changes to the caller; the parent reviews results and performs any remaining validation.
146
219
  Each node gets a new Pi AI context containing only the Braid goal, its node prompt,
147
220
  labelled direct predecessor outputs, and workspace metadata. It receives Pi's
148
221
  `read` and `ls`, plus `grep` when local `rg` is available and `find` when
149
222
  local `fd`/`fdfind` is available. Missing search dependencies are reported in the
150
223
  node prompt, with `ls`/`read` as alternatives. Dependencies are checked before
151
224
  exposing search tools and again before executing them; missing tools are not
152
- installed by Braid. Git worktrees additionally receive `write` and `edit`.
153
- It receives no parent transcript, shell tools, test runner, skills, or arbitrary
154
- code execution. Decision nodes additionally receive `decide`. Git nodes receive
225
+ installed automatically by Braid. Writable workspaces additionally receive `write`,
226
+ `edit`, and Pi's `bash` tool (`powershell` is also exposed on Windows), so nodes
227
+ can install local dependencies, build, and run tests. Read-only nodes have no shell.
228
+ Nodes receive no parent transcript, skills, or inherited extension/MCP tools.
229
+ Decision nodes additionally receive `decide`. Git nodes receive
155
230
  local Git inspection; merge/integrate nodes also receive Git integration commands and
156
231
  `finish_merge`. Merge agents receive bounded changed-file lists, diff statistics
157
232
  and previews; integrate also receives the source checkout's dirty status. The model-facing `git`
@@ -160,7 +235,7 @@ only the current source IDs and diagnoses missing, duplicate or unexpected IDs.
160
235
 
161
236
  ## Install from npm
162
237
 
163
- Requires Node.js 22.19+ and Pi 0.87.1 (the tested version):
238
+ Requires Node.js 22.19+ and Pi 1.0.1 (the tested version):
164
239
 
165
240
  ```sh
166
241
  pi install npm:@chrok/pi-braid
@@ -171,7 +246,7 @@ The package depends on the exact matching `@chrok/braid` release; npm installs
171
246
  core automatically. It does not bundle core or depend on a source checkout.
172
247
  Pi supplies its core peer packages at runtime.
173
248
  Their wildcard ranges follow Pi's packaging convention, not universal version
174
- compatibility. Development and CI pin Pi 0.87.1.
249
+ compatibility. Development and CI pin Pi 1.0.1.
175
250
 
176
251
  ## Install this local checkout in Pi
177
252
 
@@ -237,7 +312,8 @@ Root executions use the initial job snapshot, including tracked and non-ignored
237
312
  untracked caller edits. Later loop rounds get new worktrees; they never reuse a
238
313
  previous invocation's workspace. `workspace: "read-only"` disables write/edit
239
314
  while retaining an isolated snapshot. Outside Git, all filesystem access is
240
- read-only. Search tools require installed `rg`/`fd`; shell/tests are unavailable.
315
+ read-only, including no shell tools. Search tools require installed `rg`/`fd`.
316
+ Writable nodes can run shell commands and tests in their assigned working directory.
241
317
 
242
318
  `merge` combines predecessor results into a new isolated worktree. `integrate`
243
319
  applies selected changes to the invoking checkout and preserves user edits. Both
@@ -261,6 +337,12 @@ results, explicitly connect them to an integrate node:
261
337
  }
262
338
  ```
263
339
 
340
+ Checkpoints save tracked changes and non-ignored new files, following normal
341
+ `git add --all` semantics. Existing tracked files remain tracked even when they
342
+ match ignore rules. Ignored dependencies, caches, and build outputs are discarded
343
+ with the worktree and are not carried to successors; keep required outputs in
344
+ non-ignored paths. Files deliberately staged with `git add --force` remain tracked.
345
+
264
346
  `braid_status` retains execution-keyed workspace paths, checkpoint refs, and target
265
347
  merge dispositions. Cleanup removes worktrees while keeping immutable checkpoints.
266
348
  Inspect with `git show <checkpointRef>:path`. Integration additionally saves a
@@ -268,10 +350,22 @@ pre-write backup ref. A failed integration can leave partial source changes or
268
350
  conflicts; core does not reset the caller's checkout. Integrations serialize
269
351
  within the process, without locking parent edits or other processes.
270
352
 
271
- Pi's guarded writes reject external paths, Git metadata, symlinks, hard links,
272
- and special files. This is a capability boundary, not an OS sandbox. A custom
273
- runner must honor the core write barrier so cancellation can drain writes before
274
- cleanup. Calling the Pi runner without an assigned workspace stays read-only.
353
+ Pi's guarded `write`/`edit` reject external paths, Git metadata, symlinks, hard links,
354
+ and special files. Shell tools run with host permissions and can bypass those
355
+ checks. Prompts require writes to stay in the assigned workspace, reserve source
356
+ checkout changes for integrate nodes, and protect shared Git refs/configuration,
357
+ other nodes, ports, databases, caches, and external services. These are cooperation
358
+ rules, not an OS sandbox. Shell tools are created for each invocation; parent
359
+ extension/MCP tools and their hooks are not inherited.
360
+
361
+ Shell calls join the core write barrier: cancellation/timeout stops the process
362
+ group (Windows uses `taskkill /T`), and checkpointing waits for the call to settle.
363
+ On POSIX, remaining children in the command's process group are also stopped on
364
+ normal command exit. Windows cleanup after the parent process exits is best effort.
365
+ Run commands in the foreground; do not daemonize or leave servers/watchers running.
366
+ Processes that detach from the group and external services are not contained by
367
+ this mechanism. A custom runner must honor the core write barrier as well.
368
+ Calling the Pi runner without an assigned workspace stays read-only.
275
369
 
276
370
  Tool and time budgets are unlimited by default in Pi. To set finite hard limits,
277
371
  pass any of these fields in the `braid` tool's `options`:
package/dist/display.js CHANGED
@@ -324,6 +324,13 @@ export function applyEvent(state, event) {
324
324
  });
325
325
  if (state.events.length > MAX_VISIBLE_EVENTS)
326
326
  state.events.shift();
327
+ if (event.type === "graph_completed" || event.type === "graph_failed") {
328
+ state.status = event.type === "graph_completed" ? "completed" : "failed";
329
+ state.pausedExecutionIds = [];
330
+ if (event.type === "graph_failed")
331
+ state.error = { ...event.error };
332
+ return;
333
+ }
327
334
  if (event.type === "node_created") {
328
335
  Object.defineProperty(state.nodes, event.nodeId, {
329
336
  value: {
@@ -421,6 +428,22 @@ export function applyProgress(state, progress) {
421
428
  writable: true,
422
429
  });
423
430
  }
431
+ /** Focused status reads must show the requested execution, including historical ones. */
432
+ export function renderNodeResult(node, expanded, theme, fullOutputPath) {
433
+ const failed = node.status === "failed";
434
+ const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
435
+ const lines = [
436
+ theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`),
437
+ ...(node.executionId ? [theme.fg("dim", `execution: ${compact(node.executionId, 80)}`)] : []),
438
+ ...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
439
+ ...(node.error ? [theme.fg("error", `${node.error.code}: ${clean(node.error.message)}`)] : []),
440
+ ...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []),
441
+ ...(node.output ? [expanded ? clean(node.output) : compact(node.output, 400)] : []),
442
+ ...(!expanded && node.output && (node.output.length > 400 || node.output.includes("\n")) ? [theme.fg("dim", "Expand for full node output")] : []),
443
+ ...(fullOutputPath ? [theme.fg("dim", `full node result: ${compact(fullOutputPath, 240)}`)] : []),
444
+ ];
445
+ return new Text(lines.join("\n"), 0, 0);
446
+ }
424
447
  export function renderGraphResult(result, expanded, isPartial, theme, fallback = "", isError = false) {
425
448
  if (isError || !result?.nodes) {
426
449
  const message = fallback ||
@@ -438,7 +461,9 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
438
461
  ? theme.fg("warning", "⟳ Braid executing")
439
462
  : result.status === "completed"
440
463
  ? theme.fg("success", "✓ Braid completed")
441
- : theme.fg("error", "✗ Braid failed");
464
+ : result.error?.code === "CANCELLED"
465
+ ? theme.fg("warning", "■ Braid cancelled")
466
+ : theme.fg("error", "✗ Braid failed");
442
467
  const lines = [
443
468
  title,
444
469
  theme.fg("muted", `${completed}/${nodes.length} completed · ${result.status === "running" ? `${active} active · ${pending} pending · ` : ""}${skipped} skipped · ${failed} failed · ${elapsed(elapsedMs)}`),
@@ -447,9 +472,9 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
447
472
  const terminals = Object.keys(result.terminalOutputs);
448
473
  if (terminals.length)
449
474
  lines.push(theme.fg("accent", `terminals: ${compact(terminals.join(", "), 120)}`));
450
- if (result.error)
451
- lines.push(theme.fg("error", `${result.error.code}: ${compact(result.error.message, 120)}`));
452
475
  }
476
+ if (result.error)
477
+ lines.push(theme.fg("error", `${result.error.code}: ${compact(result.error.message, 120)}`));
453
478
  if ("pausedExecutionIds" in result && result.pausedExecutionIds?.length)
454
479
  lines.push(theme.fg("warning", `${result.pausedExecutionIds.length} paused executions · revision ${result.revision ?? 0}`));
455
480
  if ("executions" in result)
package/dist/index.js CHANGED
@@ -6,54 +6,88 @@ 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
8
  import { registerBraidCommand } from "./command.js";
9
- import { renderGraphCall, renderGraphResult } from "./display.js";
10
- const text = () => Type.String({ minLength: 1 });
9
+ import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js";
10
+ const text = (description) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
11
11
  const prompt = () => Type.Union([
12
- text(),
12
+ text("Instructions for this node; it does not receive the parent conversation."),
13
13
  Type.Object({
14
- template: text(),
15
- variables: Type.Record(Type.String(), Type.String()),
14
+ template: text("Exact key in promptTemplates."),
15
+ variables: Type.Record(Type.String(), Type.String(), {
16
+ description: "Exactly the template's placeholder names with string values; use {} if there are no placeholders. No missing or extra keys.",
17
+ }),
16
18
  }, { additionalProperties: false }),
17
- ]);
18
- const timeout = () => Type.Optional(Type.Number({
19
+ ], { description: "Required for execute/decision; optional for merge/integrate. A non-blank instruction string or a reference to a declared prompt template." });
20
+ const timeout = (scope) => Type.Optional(Type.Number({
19
21
  exclusiveMinimum: 0,
20
22
  maximum: 2_147_483_647,
21
- description: "Timeout in milliseconds; omit for no time limit",
23
+ description: scope === "graph"
24
+ ? "Total wall-clock timeout in milliseconds, including queueing and pauses. Updates/resume do not reset it; reserve time for final integration. Omit for no time limit."
25
+ : "Timeout in milliseconds per node execution; omit for no time limit.",
22
26
  }));
23
27
  const toolBudget = (unit) => Type.Optional(Type.Integer({
24
28
  minimum: 1,
25
29
  maximum: Number.MAX_SAFE_INTEGER,
26
30
  description: `Maximum tool ${unit} per node, including decide and rejected requests; omit for no limit`,
27
31
  }));
28
- const nodeParameters = Type.Object({
29
- type: StringEnum(["execute", "decision", "merge", "integrate"]),
30
- id: text(), prompt: Type.Optional(prompt()), model: Type.Optional(text()),
32
+ const commonNodeParameters = {
33
+ id: text("Unique node definition ID; edges refer to this exact ID. This is not an executionId."),
34
+ model: Type.Optional(text("Model override as provider/model-id; omit to use the parent model.")),
31
35
  notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Send an execution completion reminder; default false. Does not pause scheduling." })),
32
36
  pauseAfter: Type.Optional(Type.Boolean({ description: "Hold this execution's outgoing dependencies until braid_resume or an atomic braid_update with resume. Sends a pause reminder." })),
33
37
  requireSuccess: Type.Optional(Type.Boolean({ description: "If true, failure aborts the entire job and cancels running siblings. Default false: unconditional successors can recover." })),
34
- workspace: Type.Optional(StringEnum(["read-only", "worktree"], { description: "Execute/decision only. Both use fresh predecessor snapshots in Git; read-only disables writes. Outside Git, all workers are read-only." })),
35
- choices: Type.Optional(Type.Array(text(), { minItems: 1, description: "Required only for decision nodes." })),
36
- }, { additionalProperties: false });
37
- const edgeParameters = Type.Object({
38
- from: text(), to: text(), choice: Type.Optional(text()),
39
- feedback: Type.Optional(text()),
40
- executionId: Type.Optional(Type.String({ minLength: 1, description: "Pin an exact completed historical source execution; available in braid_update only." })),
38
+ };
39
+ const workspace = Type.Optional(StringEnum(["read-only", "worktree"], {
40
+ description: "Only for execute/decision; omit for merge/integrate. Defaults to worktree in Git. Both modes use fresh predecessor snapshots; read-only disables writes and shell tools. Outside Git, all workers are read-only.",
41
+ }));
42
+ // Keep all fields visible in one object. Kimi sessions using object-variant
43
+ // unions repeatedly emitted bare merge nodes instead of intended executions.
44
+ // Core validates the conditional requirements before starting or changing a job.
45
+ const nodeParameters = Type.Object({
46
+ id: commonNodeParameters.id,
47
+ type: StringEnum(["execute", "decision", "merge", "integrate"], {
48
+ description: "execute: analyze/implement; decision: choose a route; merge: combine predecessor checkpoints in an isolated worktree; integrate: apply predecessor changes to the invoking checkout.",
49
+ }),
50
+ prompt: Type.Optional(prompt()),
51
+ choices: Type.Optional(Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Required only for decision; omit for every other type. Distinct routing labels used by decide and outgoing choice edges." })),
52
+ workspace,
53
+ model: commonNodeParameters.model,
54
+ notifyOnCompletion: commonNodeParameters.notifyOnCompletion,
55
+ pauseAfter: commonNodeParameters.pauseAfter,
56
+ requireSuccess: commonNodeParameters.requireSuccess,
57
+ }, { additionalProperties: false, description: "execute/decision REQUIRE prompt; decision also REQUIRES choices. merge/integrate may omit prompt but MUST omit choices and workspace. Omit unused optional fields. All types accept model, notifyOnCompletion, pauseAfter, and requireSuccess." });
58
+ const edgeFields = {
59
+ from: text("Source node ID."),
60
+ to: text("Target node ID."),
61
+ choice: Type.Optional(text("Only for a decision source: one exact declared choice. Omit for an unconditional dependency, including error recovery.")),
62
+ feedback: Type.Optional(text("Loop ID, only on the single back edge from its decision to its entry. Requires choice and a matching loops definition; not a boolean. Cannot be combined with executionId.")),
63
+ };
64
+ const edgeParameters = Type.Object(edgeFields, { additionalProperties: false });
65
+ const updateEdgeParameters = Type.Object({
66
+ ...edgeFields,
67
+ executionId: Type.Optional(text("Pin a completed historical source execution from braid_status; from must match that execution's node ID. Available only in braid_update, never in the initial graph.")),
41
68
  }, { additionalProperties: false });
42
- const loopParameters = Type.Object({ id: text(), entry: text(), maxIterations: Type.Integer({ minimum: 1 }) }, { additionalProperties: false });
43
- const templates = Type.Record(Type.String(), text());
69
+ const loopParameters = Type.Object({
70
+ id: text("Unique loop ID referenced by exactly one edge.feedback."),
71
+ entry: text("Node ID where every iteration starts; the feedback edge must target this node."),
72
+ maxIterations: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Total rounds including the first, not the number of retries. Choosing feedback on the last round fails with LOOP_LIMIT." }),
73
+ }, { additionalProperties: false, description: "A bounded loop with one entry and a decision that selects retry or exit. The body is acyclic after removing the feedback edge; external edges enter only at entry and leave only from that decision. Loops cannot overlap or nest." });
74
+ const templateDescription = "Named prompt strings. Placeholders use {{name}} with names matching [A-Za-z_][A-Za-z0-9_]*. Node variables must match exactly; string values are inserted literally.";
75
+ const templates = Type.Record(Type.String(), text(), { description: templateDescription });
44
76
  const braidParameters = Type.Object({
45
- goal: text(), nodes: Type.Array(nodeParameters, { minItems: 1 }), edges: Type.Array(edgeParameters),
77
+ goal: text("Shared goal included in every worker's context."),
78
+ nodes: Type.Array(nodeParameters, { minItems: 1 }),
79
+ edges: Type.Array(edgeParameters, { description: "Dependencies between node IDs; use [] for independent roots. Cycles require a declared loop and explicit feedback edge. Historical executionId is not allowed at submission." }),
46
80
  promptTemplates: Type.Optional(templates), loops: Type.Optional(Type.Array(loopParameters)),
47
81
  options: Type.Optional(Type.Object({
48
- maxConcurrency: Type.Optional(Type.Integer({ minimum: 1 })),
49
- maxExecutions: Type.Optional(Type.Integer({ minimum: 1, description: "Total execution limit across all iterations and updates; default 1000." })),
50
- nodeTimeoutMs: timeout(), graphTimeoutMs: timeout(), maxToolRounds: toolBudget("rounds"), maxToolCalls: toolBudget("calls"),
82
+ maxConcurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneous node executions; default 4." })),
83
+ maxExecutions: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Total execution limit across all iterations and updates; default 1000." })),
84
+ nodeTimeoutMs: timeout("node"), graphTimeoutMs: timeout("graph"), maxToolRounds: toolBudget("rounds"), maxToolCalls: toolBudget("calls"),
51
85
  }, { additionalProperties: false })),
52
86
  }, { additionalProperties: false });
53
87
  const BRAID_FILESYSTEM_GUIDANCE = "Every execute/decision activation gets a fresh worktree in Git, based on its predecessor execution checkpoint (roots use the initial job snapshot). Set workspace=read-only to disable writes. Multiple independent code snapshots require an explicit merge node. " +
54
- "merge combines selected predecessor checkpoints into a new isolated worktree; integrate writes selected changes to the invoking checkout while preserving user edits. Both must call finish_merge using executionId for each source. There is no automatic final integration. " +
88
+ "merge combines selected predecessor checkpoints into a new isolated worktree; integrate writes selected changes to the invoking checkout while preserving user edits. Workers apply changes with Git/file tools, then call finish_merge using executionId for each source; finish_merge only records dispositions and does not apply changes. There is no automatic final integration. " +
55
89
  "Do not set workspace on merge/integrate nodes. Checkpoints remain recoverable after cleanup. Optional failed predecessors pass errors and partial work along unconditional edges; requireSuccess=true makes failure abort the job. " +
56
- "Nodes have local read/ls and Git inspection; writable nodes have write/edit, merge/integrate have local Git integration tools. Search tools require local rg/fd. Outside Git all filesystem access is read-only. Shell, tests, network Git, and recursive Braid calls are unavailable. Run tests in the parent after explicit integration.";
90
+ "Nodes have local read/ls and Git inspection; writable nodes have write/edit and Pi shell tools for dependencies, builds, and tests, while merge/integrate also have local Git integration tools. Search tools require local rg/fd. Outside Git all filesystem access is read-only; read-only nodes have no shell. Worktrees are not an OS sandbox: prompts constrain shell writes and shared resources. Checkpoints omit ignored new files. Parent extension/MCP tools and recursive Braid calls are not provided. Nodes should verify their changes; the parent reviews results and performs any remaining validation after integration.";
57
91
  const BRAID_USAGE_GUIDANCE = [
58
92
  "Braid is a proactive execution primitive, not only a user-requested command.",
59
93
  "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 handled independently. Nodes can analyze the project and implement changes in isolated Git worktrees. Do this without waiting for the user to say Braid; do not read everything in the parent and then decide whether to delegate.",
@@ -61,29 +95,57 @@ const BRAID_USAGE_GUIDANCE = [
61
95
  "For repeated instructions, define promptTemplates once and use prompt={template: name, variables: {name: value}} on nodes. Values are strings inserted literally into {{name}} placeholders; plain-string prompts remain supported.",
62
96
  "When Braid fits, submit a graph: use parallel execute nodes for independent analysis or implementation, execute nodes to synthesize findings, merge nodes to combine code snapshots, and integrate nodes to apply changes to the working branch. 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.",
63
97
  "Set notifyOnCompletion=true on selected nodes to receive intermediate success/failure reminders. Use braid_status({jobId, executionId}) to retrieve the exact execution; nodeId selects the latest instance. Use pauseAfter=true to hold outgoing scheduling. Definitions can always be changed with braid_update using expectedRevision; existing executions retain their captured inputs. Use resume in the same update to apply changes and release held executions atomically, or braid_resume for no graph changes.",
64
- "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.",
98
+ "Make the delegation choice once per user request. Reminders and definition errors are not new tasks. Check the accepted node types and settings in the tool response. If the same definition problem recurs, stop resubmitting and report the concrete mismatch; do not launch repeated probe/replacement jobs. In braid_update, add new nodes together with their dependencies and loops: a node added without incoming edges can start immediately, even while another execution is paused.",
99
+ "Do not use braid for a simple one-step answer, a trivial direct edit, a single shell command, or when decomposition adds no value. Writable nodes can implement and test their work. The parent reviews results and performs any remaining validation after Braid completes.",
65
100
  ].join("\n");
101
+ function isJobSnapshot(value) {
102
+ // Pi uses details={} for validation/execution errors, including before execute runs.
103
+ if (!value || typeof value !== "object")
104
+ return false;
105
+ const job = value;
106
+ return typeof job.jobId === "string" && job.jobId.length > 0 &&
107
+ ["running", "completed", "failed", "cancelled"].includes(job.status ?? "") &&
108
+ !!job.live && typeof job.live.nodes === "object" && job.live.nodes !== null;
109
+ }
110
+ function executionControl(job) {
111
+ return job.execution && {
112
+ status: job.execution.status,
113
+ revision: job.execution.revision,
114
+ pausedExecutionIds: job.execution.pausedExecutionIds,
115
+ };
116
+ }
117
+ function graphReceipt(job) {
118
+ const graph = job.execution?.graph;
119
+ return graph && {
120
+ // Echo accepted types/policies without duplicating potentially large prompts.
121
+ nodes: graph.nodes.map(({ prompt: _prompt, ...node }) => node),
122
+ edges: graph.edges,
123
+ ...(graph.loops ? { loops: graph.loops } : {}),
124
+ };
125
+ }
66
126
  export function createBraidTools(jobs) {
67
127
  const braidTool = defineTool({
68
128
  name: "braid",
69
129
  label: "Braid",
70
130
  description: "Use this tool FIRST for nontrivial engineering work: code reviews, bug investigations, design comparisons, test planning, and changes spanning multiple files. " +
71
131
  "It starts a background job and immediately returns jobId for a mutable graph of isolated LLM invocations with parallel branches and joins; the user does not need to mention Braid. " +
72
- "Use execute, decision, merge, or integrate nodes. Decision nodes must declare choices and call decide; matching choice edges activate together. Merge/integrate nodes accept multiple predecessors and an optional prompt. Structured loops declare id, entry, maxIterations, and one decision feedback edge labelled feedback=loopId; the body must be acyclic and loops cannot overlap or nest. Each round uses new executions and worktrees. " +
132
+ "Use execute, decision, merge, or integrate nodes. Execute/decision nodes require prompt. Only decision nodes declare choices and call decide; matching choice edges activate together. Merge/integrate nodes accept multiple predecessors and an optional prompt, with no workspace or choices field. " +
133
+ "Structured loops declare id, entry, maxIterations, and one back edge from a decision to entry with both choice and feedback=loopId. The decision also needs an exit choice. External edges enter only at entry and leave only through that decision; the body must be acyclic and loops cannot overlap or nest. Each round uses new executions and worktrees. " +
134
+ 'Example retry loop: nodes work (execute with prompt) and check (decision with prompt and choices ["retry","done"]); edges [{"from":"work","to":"check"},{"from":"check","to":"work","choice":"retry","feedback":"retryLoop"}]; loops [{"id":"retryLoop","entry":"work","maxIterations":3}]. An optional done edge leaves check to a downstream node. ' +
73
135
  "For repeated prompts, define promptTemplates and set node prompt to {template: name, variables: {name: value}}; core renders {{name}} placeholders using explicit string variables before execution. " +
74
136
  "Unlabelled edges are unconditional. Joins wait for all possible predecessor paths to resolve. " +
75
137
  "Nodes see only the goal, their prompt, labelled direct-predecessor outputs, and their filesystem capabilities: " +
76
- "no parent history, shell, tests, or recursive Braid calls. " +
138
+ "no parent history, inherited extension/MCP tools, or recursive Braid tools. " +
77
139
  BRAID_FILESYSTEM_GUIDANCE + " " +
78
140
  "Do not use it for a simple one-step answer or trivial direct edit. " +
79
141
  "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. " +
80
- "Set notifyOnCompletion=true on selected nodes for intermediate success/failure reminders; retrieve their output/error with braid_status({jobId, nodeId}). Notifications do not pause scheduling or allow graph mutation. " +
142
+ "Set notifyOnCompletion=true on selected nodes for intermediate success/failure reminders; retrieve their output/error with braid_status({jobId, nodeId}). Notifications do not pause scheduling; use pauseAfter for a hold, and braid_update to edit definitions. " +
81
143
  "Read result.status: failed graphs can still return successful terminal outputs.",
82
- promptSnippet: "Use FIRST for nontrivial code review/debug/design/implementation work; set workspace=read-only for analysis/synthesis, use worktrees for edits and merge nodes for integration",
144
+ promptSnippet: "Use FIRST for nontrivial code review/debug/design/implementation work; set workspace=read-only for analysis/synthesis, use worktrees for edits, merge to combine snapshots, and integrate to apply changes",
83
145
  promptGuidelines: [
84
146
  "Call braid before direct repository inspection when a code task has two or more separable review, debugging, design, test-planning, or implementation concerns; the Braid nodes can inspect the project and edit isolated Git worktrees.",
85
147
  "Use parallel execute nodes for independent analysis or implementation, execute nodes to synthesize findings, merge nodes to combine code snapshots, and integrate nodes to apply changes to the working branch. The user does not need to mention Braid or design the graph.",
86
- "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.",
148
+ "Do not use braid for simple one-step answers or trivial direct edits. Let writable nodes run shell commands and tests for their work; use merge nodes to combine and validate snapshots before explicit integration.",
87
149
  BRAID_FILESYSTEM_GUIDANCE,
88
150
  "Decision nodes additionally receive decide. Nodes cannot call recursive Braid.",
89
151
  "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.",
@@ -92,14 +154,17 @@ export function createBraidTools(jobs) {
92
154
  renderCall(args, theme) {
93
155
  return renderGraphCall(args, theme);
94
156
  },
95
- renderResult(result, _options, theme) {
157
+ renderResult(result, options, theme, ctx) {
96
158
  const job = result.details;
97
- return new Text(theme.fg(job ? "accent" : "error", job
98
- ? `Braid background job ${job.jobId} · ${job.status} · /braid to view`
99
- : result.content
100
- .filter((item) => item.type === "text")
101
- .map((item) => item.text)
102
- .join("\n")), 0, 0);
159
+ const fallback = result.content
160
+ .filter((item) => item.type === "text")
161
+ .map((item) => item.text)
162
+ .join("\n");
163
+ if (ctx.isError || !isJobSnapshot(job)) {
164
+ const pending = options.isPartial && !ctx.isError;
165
+ return new Text(theme.fg(pending ? "warning" : "error", pending ? fallback || "Braid submission pending…" : `✗ ${fallback || "Braid submission failed without error details"}`), 0, 0);
166
+ }
167
+ return new Text(theme.fg(job.status === "failed" ? "error" : "accent", `Braid background job ${job.handle ?? job.jobId} · ${job.status} · /braid to view${job.error ? `\n${job.error}` : ""}`), 0, 0);
103
168
  },
104
169
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
105
170
  signal?.throwIfAborted();
@@ -119,7 +184,8 @@ export function createBraidTools(jobs) {
119
184
  jobId: job.handle,
120
185
  canonicalJobId: job.jobId,
121
186
  status: job.status,
122
- message: "Running in background. Use braid_status to retrieve progress/results. A completion reminder will resume you; do not poll repeatedly.",
187
+ graph: graphReceipt(job),
188
+ message: "Running in background. Check the accepted graph above. Use braid_status to retrieve progress/results. Reminders arrive after the current step or resume you when idle; do not poll repeatedly.",
123
189
  }),
124
190
  },
125
191
  ],
@@ -132,23 +198,36 @@ export function createBraidTools(jobs) {
132
198
  },
133
199
  });
134
200
  const statusParameters = Type.Object({
135
- jobId: Type.Optional(text()),
136
- executionId: Type.Optional(text()),
137
- nodeId: Type.Optional(Type.String({ minLength: 1, description: "Exact node ID; requires jobId. Retrieve this node's full output/error even while the job is running." })),
201
+ jobId: Type.Optional(text("Exact session handle (e.g. job-1) or UUID; omit all IDs to list jobs.")),
202
+ 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
+ nodeId: Type.Optional(text("Exact node ID; requires jobId. Retrieve this node's latest full output/error even while the job is running.")),
138
204
  }, { additionalProperties: false });
139
205
  const statusTool = defineTool({
140
206
  name: "braid_status",
141
207
  label: "Braid status",
142
- 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. Large results include a path to the full JSON. Prefer the short handle from submission/reminders. Omit both IDs to list jobs in this session. Completion reminders arrive automatically; avoid repeated polling.",
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.",
143
209
  parameters: statusParameters,
144
- renderResult(result, options, theme) {
210
+ renderResult(result, options, theme, ctx) {
145
211
  const job = result.details;
146
212
  const fallback = result.content
147
213
  .filter((item) => item.type === "text")
148
214
  .map((item) => item.text)
149
215
  .join("\n");
150
- if (!job)
151
- return new Text(fallback, 0, 0);
216
+ if (ctx.isError)
217
+ return new Text(theme.fg("error", `✗ ${fallback || "Braid status failed without error details"}`), 0, 0);
218
+ if (!isJobSnapshot(job))
219
+ return new Text(fallback || "Braid returned no job details", 0, 0);
220
+ // Older saved sessions stored only the job snapshot for focused reads.
221
+ const state = job.result ?? job.execution;
222
+ const executionId = ctx.args?.executionId;
223
+ const nodeId = ctx.args?.nodeId;
224
+ const selectedNode = job.selectedNode ?? (executionId
225
+ ? state && Object.hasOwn(state.executions, executionId) ? state.executions[executionId] : undefined
226
+ : nodeId && state && Object.hasOwn(state.nodes, nodeId) ? state.nodes[nodeId] : undefined);
227
+ if (selectedNode)
228
+ return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath);
229
+ if (executionId || nodeId)
230
+ return new Text(fallback || "Braid returned no node details", 0, 0);
152
231
  if (job.error)
153
232
  return new Text(theme.fg("error", `Braid ${job.status}: ${job.error}`), 0, 0);
154
233
  return renderGraphResult({
@@ -171,31 +250,60 @@ export function createBraidTools(jobs) {
171
250
  throw jobs.unknownJob(params.jobId);
172
251
  if (params.nodeId !== undefined || params.executionId !== undefined) {
173
252
  const node = jobs.getNode(params.jobId, params.nodeId, params.executionId);
174
- const full = JSON.stringify({ jobId: job.jobId, handle: job.handle, status: job.status, node }, null, 2);
253
+ const { id, executionId, status, error, output, ...nodeDetails } = node;
254
+ const full = JSON.stringify({
255
+ jobId: job.jobId, handle: job.handle, status: job.status, execution: executionControl(job),
256
+ node: { id, executionId, status, error, output, ...nodeDetails },
257
+ }, null, 2);
175
258
  const preview = truncateHead(full);
176
259
  let suffix = "";
260
+ let nodeOutputPath;
177
261
  if (preview.truncated) {
178
262
  const directory = await mkdtemp(join(tmpdir(), "braid-node-result-"));
179
263
  const path = join(directory, "result.json");
180
264
  await writeFile(path, full, { mode: 0o600 });
265
+ nodeOutputPath = path;
181
266
  suffix = `\n[Preview truncated. Full node result: ${path}]`;
182
267
  }
183
268
  // Focused reads do not claim the whole job's usage; final job retrieval does.
184
- return { content: [{ type: "text", text: preview.content + suffix }], details: job };
269
+ return { content: [{ type: "text", text: preview.content + suffix }], details: {
270
+ ...job, selectedNode: node, ...(nodeOutputPath ? { nodeOutputPath } : {}),
271
+ } };
272
+ }
273
+ const { jobId, handle, status, error, usage: providerUsage, fullOutputPath, execution, ...snapshot } = job;
274
+ // Put control/error/accounting fields before large prompts, outputs and logs.
275
+ const full = JSON.stringify({
276
+ jobId, handle, status,
277
+ error: error ?? job.result?.error ?? execution?.error,
278
+ usage: providerUsage, fullOutputPath,
279
+ execution: execution && { ...executionControl(job), ...execution },
280
+ ...snapshot,
281
+ }, null, 2);
282
+ const preview = truncateHead(full);
283
+ let suffix = "";
284
+ let details = job;
285
+ if (preview.truncated) {
286
+ if (job.fullOutputPath)
287
+ suffix = `\n[Preview truncated. Full result/log: ${job.fullOutputPath}]`;
288
+ else {
289
+ const directory = await mkdtemp(join(tmpdir(), "braid-status-"));
290
+ const path = join(directory, "status.json");
291
+ await writeFile(path, full, { mode: 0o600 });
292
+ details = { ...job, fullOutputPath: path };
293
+ suffix = `\n[Preview truncated. Full status snapshot: ${path}]`;
294
+ }
185
295
  }
186
- const preview = truncateHead(JSON.stringify(job, null, 2));
187
- const suffix = preview.truncated
188
- ? `\n[Preview truncated. ${job.fullOutputPath ? `Full result/log: ${job.fullOutputPath}` : "Full results will be available when the job finishes."}]`
189
- : "";
190
- const usage = jobs.claimUsage(job.jobId);
296
+ // Saving a running snapshot can race job completion; claim usage only
297
+ // when this response actually contains a terminal snapshot.
298
+ const usage = job.status === "running" ? undefined : jobs.claimUsage(job.jobId);
191
299
  return {
192
300
  content: [{ type: "text", text: preview.content + suffix }],
193
- details: job,
301
+ details,
194
302
  ...(usage ? { usage } : {}),
195
303
  };
196
304
  },
197
305
  });
198
- const cancelParameters = Type.Object({ jobId: text() }, { additionalProperties: false });
306
+ const cancelParameters = Type.Object({ jobId: text("Exact session handle (e.g. job-1) or UUID returned by braid or braid_status.") }, { additionalProperties: false });
199
307
  const cancelTool = defineTool({
200
308
  name: "braid_cancel",
201
309
  label: "Cancel Braid",
@@ -217,23 +325,37 @@ export function createBraidTools(jobs) {
217
325
  },
218
326
  });
219
327
  const updateParameters = Type.Object({
220
- jobId: text(), expectedRevision: Type.Integer({ minimum: 0 }),
221
- upsertNodes: Type.Optional(Type.Array(nodeParameters)), removeNodeIds: Type.Optional(Type.Array(text())),
222
- addEdges: Type.Optional(Type.Array(edgeParameters)), removeEdges: Type.Optional(Type.Array(edgeParameters)),
223
- promptTemplates: Type.Optional(templates), loops: Type.Optional(Type.Array(loopParameters)),
224
- resume: Type.Optional(Type.Array(text())),
328
+ jobId: text("Exact session handle (e.g. job-1) or UUID."),
329
+ expectedRevision: Type.Integer({ minimum: 0, description: "Current execution.revision from braid_status. A stale revision rejects the entire update." }),
330
+ upsertNodes: Type.Optional(Type.Array(nodeParameters, { description: "Complete node definitions to add or replace, not partial patches. Add dependencies and loops in this SAME update: new roots may start immediately even if another execution is paused. Already admitted executions keep their captured definitions." })),
331
+ removeNodeIds: Type.Optional(Type.Array(text(), { description: "Node IDs to remove, together with all incident edges." })),
332
+ addEdges: Type.Optional(Type.Array(updateEdgeParameters, { description: "Edges to add. Pin a historical source using executionId when needed." })),
333
+ removeEdges: Type.Optional(Type.Array(updateEdgeParameters, { description: "Exact edge identities to remove: copy from, to, and every present choice/feedback/executionId from execution.graph.edges. Omitted optional fields do not act as wildcards." })),
334
+ promptTemplates: Type.Optional(Type.Record(Type.String(), text(), { description: `${templateDescription} Replaces the entire template map; omit to preserve it. Include every template still referenced by nodes.` })),
335
+ loops: Type.Optional(Type.Array(loopParameters, { description: "Replaces all loop definitions; omit to preserve them, or use [] to remove all (also remove their feedback edges)." })),
336
+ resume: Type.Optional(Type.Array(text(), { uniqueItems: true, description: "Paused execution IDs from execution.pausedExecutionIds, not node IDs. Releases them atomically with the graph edit." })),
225
337
  }, { additionalProperties: false });
226
338
  const updateTool = defineTool({
227
339
  name: "braid_update", label: "Update Braid",
228
- description: "Atomically edit a running or waiting job using its current expectedRevision from braid_status. Upserts replace complete node definitions; removeNodeIds also removes incident edges. Existing executions and their failure policy remain unchanged. Completion routes through the latest graph. Pin historical inputs with edge.executionId. Optional resume releases paused execution IDs in the same transaction. Rejected updates change nothing; finalized jobs cannot be reopened.",
340
+ description: "Atomically edit a running or waiting job using its current expectedRevision from braid_status. Upserts replace complete node definitions; removeNodeIds also removes incident edges. Submit new nodes, dependencies, loops, and optional resume together; do not stage disconnected nodes in separate calls, as they can start immediately. Existing executions and their failure policy remain unchanged. Completion routes through the latest graph. Pin historical inputs with edge.executionId. Optional resume releases paused execution IDs in the same transaction. Rejected updates change nothing; retry the entire corrected patch. Finalized jobs cannot be reopened.",
229
341
  parameters: updateParameters,
230
342
  async execute(_id, params) {
231
343
  const { jobId, ...patch } = params;
232
- const job = jobs.update(jobId, patch);
233
- return { content: [{ type: "text", text: JSON.stringify({ jobId: job.handle, revision: job.execution.revision, pausedExecutionIds: job.execution.pausedExecutionIds }) }], details: job };
344
+ let job;
345
+ try {
346
+ job = jobs.update(jobId, patch);
347
+ }
348
+ catch (error) {
349
+ throw new Error(`Braid update rejected; no changes or resumes were applied. ${error instanceof Error ? error.message : String(error)}. Retry the complete corrected patch, including its edges/loops/resume; adding disconnected nodes separately can start them immediately.`);
350
+ }
351
+ return { content: [{ type: "text", text: JSON.stringify({ jobId: job.handle, revision: job.execution.revision, pausedExecutionIds: job.execution.pausedExecutionIds, graph: graphReceipt(job) }) }], details: job };
234
352
  },
235
353
  });
236
- const resumeParameters = Type.Object({ jobId: text(), expectedRevision: Type.Integer({ minimum: 0 }), executionIds: Type.Array(text(), { minItems: 1 }) }, { additionalProperties: false });
354
+ const resumeParameters = Type.Object({
355
+ jobId: text("Exact session handle (e.g. job-1) or UUID."),
356
+ expectedRevision: Type.Integer({ minimum: 0, description: "Current execution.revision from braid_status." }),
357
+ executionIds: Type.Array(text(), { minItems: 1, uniqueItems: true, description: "IDs from execution.pausedExecutionIds, not node IDs. Only these completed, paused executions are released." }),
358
+ }, { additionalProperties: false });
237
359
  const resumeTool = defineTool({
238
360
  name: "braid_resume", label: "Resume Braid",
239
361
  description: "Release specific paused execution IDs using expectedRevision from braid_status. Independent branches keep running during a pause; this does not reset deadlines or execution limits.",
@@ -249,7 +371,9 @@ export default function braidExtension(pi) {
249
371
  const pending = new Map();
250
372
  const remind = (message) => {
251
373
  try {
252
- pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" });
374
+ // Pi consumes steering after the assistant response and its entire tool
375
+ // batch. followUp waits until the whole foreground task would stop.
376
+ pi.sendMessage(message, { triggerTurn: true, deliverAs: "steer" });
253
377
  }
254
378
  catch {
255
379
  // Keep failed deliveries pending for the next settled retry.
@@ -273,13 +397,13 @@ export default function braidExtension(pi) {
273
397
  const message = {
274
398
  customType: "braid-node-completed",
275
399
  display: true,
276
- content: `[system-reminder] Braid job ${handle} node ${JSON.stringify(nodeId)} execution ${executionId}${iteration ? ` (iteration ${iteration})` : ""} finished with status ${status}${errorCode ? ` (${errorCode})` : ""}. Retrieve its output or error with braid_status(${lookup}) and continue the original task. ${paused ? "Outgoing scheduling is paused. Inspect the current revision, then use braid_update with resume or braid_resume to continue. Independent branches may still be running." : "This execution reminder does not pause downstream scheduling; the job may still be running."} [/system-reminder]`,
400
+ content: `[system-reminder] Braid job ${handle} node ${JSON.stringify(nodeId)} execution ${executionId}${iteration ? ` (iteration ${iteration})` : ""} finished with status ${status}${errorCode ? ` (${errorCode})` : ""}. Retrieve its output or error with braid_status(${lookup}) and continue the original task. ${paused ? "Outgoing scheduling was paused at completion. Inspect the current status, revision, and pausedExecutionIds; if this execution is still paused, use braid_update with resume or braid_resume to continue. Independent branches may still be running. A finalized job cannot be resumed." : "This execution reminder does not pause downstream scheduling; the job may still be running."} [/system-reminder]`,
277
401
  details: { ...completion, reminderId },
278
402
  };
279
403
  pending.set(reminderId, message);
280
404
  remind(message);
281
405
  });
282
- // Foreground cancellation can discard queued follow-ups. Retry only reminders
406
+ // Foreground cancellation can discard queued steering. Retry only reminders
283
407
  // that never entered context, once Pi has settled and emptied its queues.
284
408
  pi.on("message_start", (event) => {
285
409
  const message = event.message;
package/dist/jobs.js CHANGED
@@ -145,7 +145,9 @@ export class BraidJobs {
145
145
  const status = job.result.error?.code === "CANCELLED"
146
146
  ? "cancelled"
147
147
  : job.result.status;
148
- const full = JSON.stringify({ ...job.result, workspaces: job.workspaces }, null, 2);
148
+ // Core metadata counts usage returned by runners; Pi also records completed
149
+ // provider rounds from workers that later fail or time out.
150
+ const full = JSON.stringify({ ...job.result, workspaces: job.workspaces, piUsage: job.usage }, null, 2);
149
151
  const preview = JSON.stringify({ ...this.get(job.jobId), status }, null, 2);
150
152
  if (truncateHead(preview).truncated) {
151
153
  const directory = await mkdtemp(join(tmpdir(), "braid-result-"));
@@ -157,6 +159,8 @@ export class BraidJobs {
157
159
  }
158
160
  catch (error) {
159
161
  job.status = "failed";
162
+ job.live.status = "failed";
163
+ job.live.pausedExecutionIds = [];
160
164
  job.error = error instanceof Error ? error.message : String(error);
161
165
  if (reports.length)
162
166
  job.usage = sumPiUsage(reports);
package/dist/runner.js CHANGED
@@ -3,6 +3,7 @@ import { StringEnum, Type, validateToolCall, } from "@earendil-works/pi-ai";
3
3
  import { formatBudgetReminder, gitToolDefinition, finishMergeToolDefinition, mergeInstructions, parseGitToolArguments, parseFinishMergeArguments, } from "@chrok/braid";
4
4
  import { createWorktreeWriteTools } from "./write-tools.js";
5
5
  import { createAvailableReadTools } from "./read-tools.js";
6
+ import { createWorkspaceShellTools } from "./shell-tools.js";
6
7
  /** Keep Pi's provider/auth plumbing and filesystem capabilities out of Braid's core. */
7
8
  export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd()) {
8
9
  const options = typeof onUsageOrOptions === "function"
@@ -61,7 +62,10 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
61
62
  const fileTools = [
62
63
  ...readTools.tools,
63
64
  ...(writeRoot
64
- ? await createWorktreeWriteTools(workingDirectory, writeRoot, request.signal, readOnlyPaths)
65
+ ? [
66
+ ...await createWorktreeWriteTools(workingDirectory, writeRoot, request.signal, readOnlyPaths),
67
+ ...createWorkspaceShellTools(workingDirectory),
68
+ ]
65
69
  : []),
66
70
  ];
67
71
  // Send only serializable definitions to the model, not execute functions.
@@ -96,12 +100,20 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
96
100
  : workspace.mode === "worktree"
97
101
  ? "You may write and edit files inside your own isolated Git worktree. Use workingDirectory as your cwd; do not write to sourceRoot or any other node's worktree. " +
98
102
  "Each execution starts from its predecessor checkpoint; root executions use the initial job snapshot. Repeated loop executions get new worktrees. Inspect exact predecessor checkpoints with git show. " +
99
- "Describe your changes in your final answer. Core will save your checkpoint and clean up the worktree. Only explicit integrate nodes write to the source checkout. "
103
+ "Describe your changes and verification in your final answer. Core will save your checkpoint and clean up the worktree. Leave source checkout changes to explicit integrate nodes. "
100
104
  : "This node has no writable workspace assigned. Its filesystem tools are read-only; you cannot write or edit files. " +
101
105
  "Read workingDirectory directly; in Git this is an isolated predecessor snapshot. Outside Git it is the source directory. " +
102
106
  (request.git ? "Use Git inspection to review changes or predecessor checkpoints; the assigned snapshot contains predecessor edits. " : "")) +
103
107
  mergeInstructions(request) +
104
- "You cannot run shell commands, run tests, or call arbitrary tools. " +
108
+ (writeRoot
109
+ ? "You may use Pi's shell tools to install local dependencies, build, run tests, and fix failures in workingDirectory. " +
110
+ "Worktrees isolate code snapshots, not host permissions: shell access is not sandboxed. Keep file changes within your assigned workspace; integrate alone may edit sourceRoot. " +
111
+ "Git refs, configuration, hooks, and object storage are shared with the caller and other nodes. Do not change shared Git configuration, hooks, branches, Braid refs, or worktree registrations, and do not switch branches. Prefer the provided git tool for inspection and merge operations. " +
112
+ "Nodes run concurrently and loop executions start fresh: ports, databases, caches, credentials, and external services are shared. Use execution-specific temporary resources, avoid global installs and destructive or externally visible actions unless explicitly requested, and do not spawn recursive Braid/Pi agents. " +
113
+ "Run commands in the foreground; command completion, timeout, or cancellation stops their process group. Do not daemonize or leave servers/watchers running. " +
114
+ "Checkpoints include tracked changes and non-ignored new files; ignored dependencies, caches, and build products are not carried to successors and are removed during cleanup. "
115
+ : "You cannot run shell commands or tests in this read-only workspace. ") +
116
+ "Parent extension/MCP tools, skills, and session history are not inherited; use only the tools provided here. " +
105
117
  ((request.node.type === "merge" || request.node.type === "integrate") && workspace.mode === "read-only"
106
118
  ? "This merge has no Git sources; call finish_merge with an empty dispositions array before answering."
107
119
  : request.node.type === "decision"
@@ -223,7 +235,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
223
235
  }
224
236
  const args = validateToolCall(allToolDefinitions, call);
225
237
  const execute = () => tool.execute(call.id, args, request.signal, undefined);
226
- const result = ["write", "edit"].includes(call.name) && request.withWorkspaceWrite
238
+ const result = ["write", "edit", "bash", "powershell"].includes(call.name) && request.withWorkspaceWrite
227
239
  ? await request.withWorkspaceWrite(execute)
228
240
  : await execute();
229
241
  request.signal.throwIfAborted();
@@ -233,7 +245,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
233
245
  toolName: call.name,
234
246
  content: result.content,
235
247
  ...(result.details === undefined ? {} : { details: result.details }),
236
- isError: false,
248
+ isError: result.isError ?? false,
237
249
  timestamp: Date.now(),
238
250
  };
239
251
  }
@@ -0,0 +1,109 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { constants } from "node:os";
3
+ import { join } from "node:path";
4
+ import { createBashTool, createPowerShellTool, getShellConfig, getPowerShellConfig, } from "@earendil-works/pi-coding-agent";
5
+ /** Pi owns the tool schema/output handling; Braid owns the command lifetime. */
6
+ function managedOperations(resolveShell, prefix = "") {
7
+ return {
8
+ async exec(command, cwd, { onData, signal, timeout, env }) {
9
+ signal?.throwIfAborted();
10
+ if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0 || timeout * 1000 > 2_147_483_647))
11
+ throw new Error("Invalid timeout: expected positive seconds within the timer limit");
12
+ const config = resolveShell();
13
+ signal?.throwIfAborted();
14
+ const stdin = config.commandTransport === "stdin";
15
+ const child = spawn(config.shell, stdin ? config.args : [...config.args, prefix + command], {
16
+ cwd, env, detached: process.platform !== "win32", windowsHide: true,
17
+ stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"],
18
+ });
19
+ const closed = new Promise(resolve => child.once("close", () => resolve()));
20
+ const exited = new Promise((resolve, reject) => {
21
+ child.once("error", reject);
22
+ child.once("exit", resolve);
23
+ });
24
+ child.stdout.on("data", onData);
25
+ child.stderr.on("data", onData);
26
+ if (stdin) {
27
+ child.stdin.on("error", () => { });
28
+ child.stdin.end(prefix + command);
29
+ }
30
+ let termination;
31
+ const terminate = () => termination ??= (async () => {
32
+ if (!child.pid)
33
+ return;
34
+ if (process.platform === "win32") {
35
+ await new Promise((resolve, reject) => {
36
+ execFile(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), ["/F", "/T", "/PID", String(child.pid)], { windowsHide: true }, error => {
37
+ // taskkill reports 128 when the process has already exited.
38
+ if (error && error.code !== 128)
39
+ reject(error);
40
+ else
41
+ resolve();
42
+ });
43
+ });
44
+ }
45
+ else {
46
+ try {
47
+ process.kill(-child.pid, "SIGKILL");
48
+ }
49
+ catch (error) {
50
+ if (error.code !== "ESRCH")
51
+ throw error;
52
+ }
53
+ }
54
+ })();
55
+ // The completion path awaits termination; event handlers must not create
56
+ // unhandled rejections while the shell is still exiting.
57
+ const stop = () => { void terminate().catch(() => { }); };
58
+ let timedOut = false;
59
+ const timer = timeout === undefined ? undefined : setTimeout(() => {
60
+ timedOut = true;
61
+ stop();
62
+ }, timeout * 1000);
63
+ signal?.addEventListener("abort", stop, { once: true });
64
+ if (signal?.aborted)
65
+ stop();
66
+ let exitCode;
67
+ try {
68
+ exitCode = await exited;
69
+ }
70
+ finally {
71
+ clearTimeout(timer);
72
+ signal?.removeEventListener("abort", stop);
73
+ // Stop leftover children even on successful command completion. Commands
74
+ // cannot leave a server/watch process writing during checkpoint/cleanup.
75
+ try {
76
+ await terminate();
77
+ }
78
+ finally {
79
+ // A daemon can leave the process group while holding inherited pipes.
80
+ // Bound pipe draining; this process-group cleanup is not an OS sandbox.
81
+ const drainTimer = setTimeout(() => {
82
+ child.stdout.destroy();
83
+ child.stderr.destroy();
84
+ }, 250);
85
+ try {
86
+ await closed;
87
+ }
88
+ finally {
89
+ clearTimeout(drainTimer);
90
+ }
91
+ }
92
+ }
93
+ if (signal?.aborted)
94
+ throw new Error("aborted");
95
+ if (timedOut)
96
+ throw new Error(`timeout:${timeout}`);
97
+ return { exitCode: exitCode ?? (child.signalCode ? 128 + (constants.signals[child.signalCode] ?? 0) : 1) };
98
+ },
99
+ };
100
+ }
101
+ export function createWorkspaceShellTools(cwd) {
102
+ return [
103
+ createBashTool(cwd, { operations: managedOperations(getShellConfig), exposeSessionEnvironment: false }),
104
+ ...(process.platform === "win32" ? [createPowerShellTool(cwd, {
105
+ operations: managedOperations(getPowerShellConfig, "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n"),
106
+ exposeSessionEnvironment: false,
107
+ })] : []),
108
+ ];
109
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@chrok/pi-braid",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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.2.0"
9
+ "@chrok/braid": "0.3.0"
10
10
  },
11
11
  "peerDependencies": {
12
12
  "@earendil-works/pi-ai": "*",
@@ -14,9 +14,9 @@
14
14
  "@earendil-works/pi-tui": "*"
15
15
  },
16
16
  "devDependencies": {
17
- "@earendil-works/pi-ai": "0.87.1",
18
- "@earendil-works/pi-tui": "0.87.1",
19
- "@earendil-works/pi-coding-agent": "0.87.1",
17
+ "@earendil-works/pi-ai": "1.0.1",
18
+ "@earendil-works/pi-tui": "1.0.1",
19
+ "@earendil-works/pi-coding-agent": "1.0.1",
20
20
  "typescript": "^5.0.0",
21
21
  "tsx": "^4.0.0",
22
22
  "@types/node": "^22.0.0"