@chrok/pi-braid 0.2.1 → 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 +111 -17
- package/dist/display.js +28 -3
- package/dist/index.js +192 -68
- package/dist/jobs.js +5 -1
- package/dist/runner.js +17 -5
- package/dist/shell-tools.js +109 -0
- package/package.json +2 -2
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.
|
|
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.
|
|
11
|
-
before upgrading
|
|
12
|
-
|
|
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
|
|
33
|
-
|
|
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
|
|
134
|
-
|
|
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
|
|
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.
|
|
153
|
-
|
|
154
|
-
|
|
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`
|
|
@@ -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
|
|
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
|
|
272
|
-
and special files.
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
:
|
|
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:
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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({
|
|
43
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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,
|
|
157
|
+
renderResult(result, options, theme, ctx) {
|
|
96
158
|
const job = result.details;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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;
|
|
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
|
-
|
|
233
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
?
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"@chrok/braid": "0.3.0"
|
|
10
10
|
},
|
|
11
11
|
"peerDependencies": {
|
|
12
12
|
"@earendil-works/pi-ai": "*",
|