@chrok/pi-braid 0.3.4 → 0.3.6
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 +70 -26
- package/dist/command.js +0 -3
- package/dist/display.js +2 -2
- package/dist/index.js +106 -102
- package/dist/jobs.js +8 -19
- package/dist/runner.js +14 -2
- package/dist/status-text.js +179 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@chrok/pi-braid)
|
|
4
4
|
|
|
5
|
-
This optional Pi extension runs Braid agent graphs as background jobs with
|
|
5
|
+
This optional Pi extension runs Braid agent graphs as background jobs by default, with
|
|
6
6
|
bounded loops, live updates, pause/resume, and a live flow panel.
|
|
7
7
|
|
|
8
8
|
**0.3 API:** The npm badge shows the published version; see
|
|
@@ -17,7 +17,8 @@ For the previous release, use the
|
|
|
17
17
|
|
|
18
18
|
It registers:
|
|
19
19
|
|
|
20
|
-
- `braid` — submit a graph with optional bounded loops
|
|
20
|
+
- `braid` — submit a graph with optional bounded loops; receive a `jobId`
|
|
21
|
+
immediately in background mode, or final results in experimental blocking mode.
|
|
21
22
|
- `braid_status` — retrieve progress and results with `{ "jobId": "..." }`, or
|
|
22
23
|
add `"executionId": "..."` for an exact invocation's full output/error.
|
|
23
24
|
`"nodeId": "..."` selects that definition's latest invocation. Omit all IDs
|
|
@@ -25,6 +26,8 @@ It registers:
|
|
|
25
26
|
- `braid_update` / `braid_resume` — edit live definitions or release paused executions.
|
|
26
27
|
- `braid_cancel` — cancel a job with `{ "jobId": "..." }`.
|
|
27
28
|
- `/braid [jobId]` — open a live flow panel in interactive Pi.
|
|
29
|
+
- `/braid:blocking [on|off]` — experimental: enable or disable blocking for new `braid` tool calls;
|
|
30
|
+
omit the argument to show the current mode.
|
|
28
31
|
- `/braid:review [target or focus]` — run a bounded parallel review/fix loop.
|
|
29
32
|
- `/braid:clear` — clear the progress widget above the editor; run it again while
|
|
30
33
|
the widget is empty to restore what was cleared.
|
|
@@ -44,10 +47,44 @@ On completion, failure, or cancellation, the extension sends a custom
|
|
|
44
47
|
Pi queues it as steering during streaming: it enters context after the current
|
|
45
48
|
assistant response and its entire tool batch, before the next model step. It
|
|
46
49
|
does not wait for the whole foreground task to finish or skip remaining tools.
|
|
47
|
-
When idle, it starts a new agent turn automatically.
|
|
48
|
-
|
|
50
|
+
When idle, it starts a new agent turn automatically. Tool descriptions, the
|
|
51
|
+
system prompt, and the submission receipt tell the agent to continue independent
|
|
52
|
+
work, or end its turn if it needs the result, and not to sleep, use a timer, or
|
|
53
|
+
poll in a loop to wait for the job.
|
|
49
54
|
Stopping the foreground response does not stop background jobs.
|
|
50
55
|
|
|
56
|
+
> **Experimental:** blocking mode is a stopgap for single-shot runs. Its flag,
|
|
57
|
+
> command, and behavior may change or be replaced by an async-preserving CI mode
|
|
58
|
+
> that keeps reminders, `pauseAfter`, and live updates
|
|
59
|
+
> ([#80](https://github.com/Epsirom/braid/issues/80)).
|
|
60
|
+
|
|
61
|
+
For CI or other single-shot runs, enable blocking manually when starting Pi:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
pi --braid-blocking -p "Use Braid to review the changes and summarize the results."
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
With `@chrok/pi-braid` installed, `--braid-blocking` makes every `braid` tool call
|
|
68
|
+
wait for the entire graph and workspace cleanup, then return final status, outputs,
|
|
69
|
+
and usage directly. The parent can summarize those results before `pi -p` exits.
|
|
70
|
+
It also works with `--mode json` for structured tool results. The CLI flag sets the
|
|
71
|
+
initial mode; within a session, use `/braid:blocking on` to enable it,
|
|
72
|
+
`/braid:blocking off` to disable it, or `/braid:blocking` to check the current mode.
|
|
73
|
+
Switching affects only new `braid` tool calls; running jobs keep their original
|
|
74
|
+
mode. The session override is not persisted. These are user controls, not model
|
|
75
|
+
tool parameters; the default remains background execution.
|
|
76
|
+
For CI pass/fail decisions, inspect the returned job status: a failed graph does
|
|
77
|
+
not by itself set Pi's process exit code.
|
|
78
|
+
Blocking calls emit an initial progress update with the job handle while waiting.
|
|
79
|
+
They suppress node/job completion reminders except for cancellation: stopping the
|
|
80
|
+
foreground response cancels their work, waits for cleanup, and sends a reminder
|
|
81
|
+
so the next turn can retrieve partial outputs and recovery checkpoints.
|
|
82
|
+
Wait/result retrieval errors include the existing job handle and any terminal
|
|
83
|
+
usage; retrieve that job with `braid_status` instead of submitting the graph again.
|
|
84
|
+
`pauseAfter: true` is rejected before starting a blocking job because it requires
|
|
85
|
+
a later resume call. The interactive
|
|
86
|
+
`/braid:review` command continues to use its background review workflow.
|
|
87
|
+
|
|
51
88
|
Jobs live in memory for the current Pi session. Quitting, reloading extensions,
|
|
52
89
|
or switching/forking sessions aborts outstanding work and suppresses its
|
|
53
90
|
reminders. Job IDs cannot be retrieved after that lifecycle ends. They are not
|
|
@@ -107,9 +144,10 @@ All four types accept `model`, `notifyOnCompletion`, `pauseAfter`, and
|
|
|
107
144
|
default to writable worktrees in Git; outside Git, all workers are read-only.
|
|
108
145
|
The model-facing schema exposes these fields in one object with a `type` enum;
|
|
109
146
|
core validation enforces the type-specific requirements before a submission or
|
|
110
|
-
update takes effect.
|
|
111
|
-
|
|
112
|
-
|
|
147
|
+
update takes effect. The submission reply gives the job ID and how to wait for
|
|
148
|
+
it; it does not repeat the graph the agent just sent. Update replies list the
|
|
149
|
+
resulting graph without prompts. If a definition problem repeats,
|
|
150
|
+
inspect/report the mismatch instead of launching more probe jobs.
|
|
113
151
|
|
|
114
152
|
Edges use exact node IDs in `from` and `to`. `choice` is an exact label declared
|
|
115
153
|
by the source decision; omitting it makes the edge unconditional. Pass `edges: []`
|
|
@@ -152,7 +190,7 @@ drops the queued message. Session shutdown suppresses delivery.
|
|
|
152
190
|
|
|
153
191
|
Set `pauseAfter: true` to hold an execution's outgoing scheduling and receive a
|
|
154
192
|
pause reminder. Independent branches continue. Fetch `braid_status` to obtain
|
|
155
|
-
|
|
193
|
+
the current graph revision and paused execution IDs, then:
|
|
156
194
|
|
|
157
195
|
```json
|
|
158
196
|
{
|
|
@@ -178,9 +216,9 @@ Pause reminders describe the event when it occurred: check current status and
|
|
|
178
216
|
paused IDs before attempting an update/resume, since the job can time out or be
|
|
179
217
|
cancelled before the parent handles the reminder.
|
|
180
218
|
|
|
181
|
-
Focused `braid_status` reads also
|
|
182
|
-
|
|
183
|
-
|
|
219
|
+
Focused `braid_status` reads also start with the current control fields: graph
|
|
220
|
+
status, revision, and paused execution IDs. Use that current revision for
|
|
221
|
+
updates even when inspecting an older invocation.
|
|
184
222
|
After cancellation or finalization there are no resumable paused executions;
|
|
185
223
|
the event log still records where pauses occurred.
|
|
186
224
|
|
|
@@ -317,9 +355,15 @@ counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status
|
|
|
317
355
|
tool also renders a flowchart; expand its result to see more log events.
|
|
318
356
|
When selecting `nodeId` or `executionId`, it instead shows that invocation's
|
|
319
357
|
output/error and activity, with the full text and history available on expansion.
|
|
320
|
-
|
|
358
|
+
|
|
359
|
+
The Braid tools return plain-text documents to the model, not JSON. A
|
|
360
|
+
whole-job read lists the control state, every execution, their outputs,
|
|
361
|
+
workspaces, and the current graph without prompts. A focused read shows one
|
|
362
|
+
execution's output and its activity with the latest 40 history entries; pass
|
|
321
363
|
`activityBefore` with an entry sequence to page earlier ones. While the invocation
|
|
322
|
-
runs,
|
|
364
|
+
runs, the activity comes before the output so it survives preview truncation.
|
|
365
|
+
Worker `git` results read like Pi's bash output, and `decide` returns
|
|
366
|
+
`Recorded choice: <choice>`.
|
|
323
367
|
|
|
324
368
|
`/braid` now opens this panel; it no longer arms the next prompt. To request
|
|
325
369
|
Braid explicitly, ask the agent to analyze the task using Braid.
|
|
@@ -330,11 +374,9 @@ model lookup, credentials/OAuth, provider transport, filesystem tool execution,
|
|
|
330
374
|
and token/cost accounting. The first
|
|
331
375
|
whole-job `braid_status` retrieval of a finished job reports its accumulated Pi usage;
|
|
332
376
|
subsequent retrievals do not count the same usage again.
|
|
333
|
-
|
|
334
|
-
rounds from nodes that later fail or time out
|
|
335
|
-
|
|
336
|
-
returned by runners. Large running status reads save a complete snapshot too,
|
|
337
|
-
so truncation never requires waiting for job completion to inspect the graph.
|
|
377
|
+
The whole-job status text reports Pi's totals as `Usage`, including completed
|
|
378
|
+
provider rounds from nodes that later fail or time out; core `metadata.usage`
|
|
379
|
+
only includes usage returned by runners.
|
|
338
380
|
|
|
339
381
|
## When Pi will use Braid
|
|
340
382
|
|
|
@@ -378,7 +420,7 @@ only the current source IDs and diagnoses missing, duplicate or unexpected IDs.
|
|
|
378
420
|
|
|
379
421
|
## Install from npm
|
|
380
422
|
|
|
381
|
-
Requires Node.js 22.19+ and Pi 1.0
|
|
423
|
+
Requires Node.js 22.19+ and Pi 1.2.0 (the tested version):
|
|
382
424
|
|
|
383
425
|
```sh
|
|
384
426
|
pi install npm:@chrok/pi-braid
|
|
@@ -389,7 +431,7 @@ The package depends on the exact matching `@chrok/braid` release; npm installs
|
|
|
389
431
|
core automatically. It does not bundle core or depend on a source checkout.
|
|
390
432
|
Pi supplies its core peer packages at runtime.
|
|
391
433
|
Their wildcard ranges follow Pi's packaging convention, not universal version
|
|
392
|
-
compatibility. Development and CI pin Pi 1.0.
|
|
434
|
+
compatibility. Development and CI pin Pi 1.2.0.
|
|
393
435
|
|
|
394
436
|
## Install this local checkout in Pi
|
|
395
437
|
|
|
@@ -595,9 +637,11 @@ fails that node cleanly and does not invoke another provider.
|
|
|
595
637
|
|
|
596
638
|
The Pi extension has no node or graph time limit by default. Use `braid_cancel`
|
|
597
639
|
or press `c` in the flow panel to abort running nodes and mark queued nodes
|
|
598
|
-
`cancelled`.
|
|
599
|
-
|
|
600
|
-
|
|
640
|
+
`cancelled`. In the default background mode, Escape stops the foreground response
|
|
641
|
+
or closes the panel without cancelling jobs. In blocking mode, stopping the
|
|
642
|
+
foreground response also cancels its Braid job. To set a node or graph timeout,
|
|
643
|
+
supply milliseconds in the submission tool input under `options`; each omitted
|
|
644
|
+
timeout remains unlimited:
|
|
601
645
|
|
|
602
646
|
```json
|
|
603
647
|
{
|
|
@@ -618,9 +662,9 @@ writes already made. Core waits for tracked writes, preserves checkpoints, and
|
|
|
618
662
|
removes worker worktrees before completing cancellation.
|
|
619
663
|
|
|
620
664
|
The tool output is capped at 50KB/2000 lines to protect Pi context. When exceeded,
|
|
621
|
-
the adapter writes
|
|
622
|
-
inherited ACLs on Windows) and
|
|
623
|
-
|
|
665
|
+
the adapter writes the complete text to a temporary file (mode 600 on POSIX;
|
|
666
|
+
inherited ACLs on Windows) and ends the preview with its path, for running jobs
|
|
667
|
+
as well as finished ones.
|
|
624
668
|
|
|
625
669
|
For a test of proactive selection, start a fresh Pi turn with a task such as:
|
|
626
670
|
|
package/dist/command.js
CHANGED
|
@@ -201,9 +201,6 @@ export class BraidPanel {
|
|
|
201
201
|
...(current.result ?? current.live),
|
|
202
202
|
progress: current.live.progress,
|
|
203
203
|
...(current.workspaces ? { workspaces: current.workspaces } : {}),
|
|
204
|
-
...(current.fullOutputPath
|
|
205
|
-
? { fullOutputPath: current.fullOutputPath }
|
|
206
|
-
: {}),
|
|
207
204
|
}, true, false, this.theme, "", false, this.choosing).render(width),
|
|
208
205
|
]
|
|
209
206
|
: ["No Braid jobs in this session."];
|
package/dist/display.js
CHANGED
|
@@ -551,7 +551,7 @@ export function renderNodeResult(node, expanded, theme, fullOutputPath, progress
|
|
|
551
551
|
...(activity && activity.executionId === node.executionId ? renderActivity(activity, expanded, theme, now) : []),
|
|
552
552
|
...(node.output ? [expanded ? clean(node.output) : compact(node.output, 400)] : []),
|
|
553
553
|
...(!expanded && node.output && (node.output.length > 400 || node.output.includes("\n")) ? [theme.fg("dim", "Expand for full node output")] : []),
|
|
554
|
-
...(fullOutputPath ? [theme.fg("dim", `full
|
|
554
|
+
...(fullOutputPath ? [theme.fg("dim", `full text: ${compact(fullOutputPath, 240)}`)] : []),
|
|
555
555
|
];
|
|
556
556
|
return new Text(lines.join("\n"), 0, 0);
|
|
557
557
|
}
|
|
@@ -655,7 +655,7 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
|
|
|
655
655
|
if (expanded)
|
|
656
656
|
lines.push(...renderEventLog(result.events ?? [], true, theme));
|
|
657
657
|
if ("fullOutputPath" in result && result.fullOutputPath)
|
|
658
|
-
lines.push(theme.fg("dim", `full
|
|
658
|
+
lines.push(theme.fg("dim", `full text: ${compact(result.fullOutputPath, 240)}`));
|
|
659
659
|
return new FixedLines(lines, chartStart, chart.length, nodeList, [
|
|
660
660
|
...(selectedNodeId === undefined ? [] : [{ mark: SELECTED_NODE_MARK, label: "selected node" }]),
|
|
661
661
|
{ mark: "▶ ACTIVE", label: "running node" },
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { BraidJobs } from "./jobs.js";
|
|
|
8
8
|
import { registerBraidCommand, registerBraidWidget } from "./command.js";
|
|
9
9
|
import { registerReviewCommand } from "./review.js";
|
|
10
10
|
import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js";
|
|
11
|
+
import { formatJobList, formatJobStatus, formatNodeStatus, formatReceipt, formatResume, formatUpdate } from "./status-text.js";
|
|
11
12
|
const text = (description) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
|
|
12
13
|
const prompt = () => Type.Union([
|
|
13
14
|
text("Instructions for this node; it does not receive the parent conversation."),
|
|
@@ -33,8 +34,8 @@ const toolBudget = (unit) => Type.Optional(Type.Integer({
|
|
|
33
34
|
const commonNodeParameters = {
|
|
34
35
|
id: text("Unique node definition ID; edges refer to this exact ID. This is not an executionId."),
|
|
35
36
|
model: Type.Optional(text("Model override as provider/model-id; omit to use the parent model.")),
|
|
36
|
-
notifyOnCompletion: Type.Optional(Type.Boolean({ description: "
|
|
37
|
-
pauseAfter: Type.Optional(Type.Boolean({ description: "
|
|
37
|
+
notifyOnCompletion: Type.Optional(Type.Boolean({ description: "In background mode, send an execution completion reminder; default false. Does not pause scheduling." })),
|
|
38
|
+
pauseAfter: Type.Optional(Type.Boolean({ description: "Background mode only: hold this execution's outgoing dependencies until braid_resume or an atomic braid_update with resume. Sends a pause reminder." })),
|
|
38
39
|
requireSuccess: Type.Optional(Type.Boolean({ description: "If true, failure aborts the entire job and cancels running siblings. Default false: unconditional successors can recover." })),
|
|
39
40
|
};
|
|
40
41
|
const workspace = Type.Optional(StringEnum(["read-only", "worktree"], {
|
|
@@ -89,14 +90,26 @@ const BRAID_FILESYSTEM_GUIDANCE = "Every execute/decision activation gets a fres
|
|
|
89
90
|
"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. " +
|
|
90
91
|
"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. " +
|
|
91
92
|
"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.";
|
|
93
|
+
/** Lifecycle guidance shared by the system prompt and tool descriptions. */
|
|
94
|
+
export const BRAID_ASYNC_GUIDANCE = "By default, Braid is asynchronous: braid returns a jobId immediately and the job keeps running independently of the foreground response; ending your turn does not cancel it. " +
|
|
95
|
+
"Completion, failure, or cancellation sends a reminder: an idle agent is resumed automatically, and a running agent receives it at the next model step. " +
|
|
96
|
+
"After submitting, continue useful independent work. If nothing else can proceed until the result arrives, end your turn and let the reminder resume the task. " +
|
|
97
|
+
"Do not call sleep, run a shell sleep, use a timer, or enter a sleep/poll loop solely to wait for a Braid job; delays required by the user's actual task remain allowed. " +
|
|
98
|
+
"On the reminder, retrieve terminal outputs with braid_status before relying on them and continue the original task. Between reminders, use braid_status only for deliberate progress checks or diagnosis. " +
|
|
99
|
+
"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.";
|
|
100
|
+
/** Short reminder repeated in every submission receipt. */
|
|
101
|
+
export const BRAID_WAIT_REMINDER = "Braid runs asynchronously and notifies you when the job finishes. Continue independent work, or end your turn if you need the result before proceeding; the completion reminder will resume you. " +
|
|
102
|
+
"Do not sleep or poll in a loop to wait for this job. Retrieve its results with braid_status after the reminder.";
|
|
103
|
+
const BRAID_BLOCKING_GUIDANCE = "In blocking mode (enabled by --braid-blocking or /braid:blocking on), braid waits for the entire graph and workspace cleanup, then returns final status, outputs, and usage directly. " +
|
|
104
|
+
"Foreground cancellation cancels the submitted job and waits for cleanup. Completion reminders are suppressed unless the job is cancelled; cancellation reminders preserve its handle and access to partial results. " +
|
|
105
|
+
"Do not set pauseAfter=true in blocking mode: it requires a later resume call and is rejected. Use braid_status for focused reads, diagnosis, or retrieving an existing job after cancellation or a wait/result error.";
|
|
92
106
|
const BRAID_USAGE_GUIDANCE = [
|
|
93
107
|
"Braid is a proactive execution primitive, not only a user-requested command.",
|
|
94
108
|
"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.",
|
|
95
109
|
BRAID_FILESYSTEM_GUIDANCE,
|
|
96
110
|
"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.",
|
|
97
|
-
"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.
|
|
98
|
-
"
|
|
99
|
-
"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.",
|
|
111
|
+
"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. Read terminal outputs before relying on them.",
|
|
112
|
+
"Make the delegation choice once per user request. Reminders and definition errors are not new tasks. 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.",
|
|
100
113
|
"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.",
|
|
101
114
|
].join("\n");
|
|
102
115
|
function isJobSnapshot(value) {
|
|
@@ -110,28 +123,22 @@ function isJobSnapshot(value) {
|
|
|
110
123
|
}
|
|
111
124
|
/** Focused reads return the latest activity entries; activityBefore pages earlier ones. */
|
|
112
125
|
export const STATUS_ACTIVITY_ENTRIES = 40;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return graph && {
|
|
123
|
-
// Echo accepted types/policies without duplicating potentially large prompts.
|
|
124
|
-
nodes: graph.nodes.map(({ prompt: _prompt, ...node }) => node),
|
|
125
|
-
edges: graph.edges,
|
|
126
|
-
...(graph.loops ? { loops: graph.loops } : {}),
|
|
127
|
-
};
|
|
126
|
+
/** Bound a text result; the complete document stays readable in a private file. */
|
|
127
|
+
async function boundedText(text, name) {
|
|
128
|
+
const preview = truncateHead(text);
|
|
129
|
+
if (!preview.truncated)
|
|
130
|
+
return { text };
|
|
131
|
+
const directory = await mkdtemp(join(tmpdir(), "braid-status-"));
|
|
132
|
+
const path = join(directory, name);
|
|
133
|
+
await writeFile(path, text, { mode: 0o600 });
|
|
134
|
+
return { text: `${preview.content}\n[Truncated. Full text: ${path}]`, path };
|
|
128
135
|
}
|
|
129
|
-
export function createBraidTools(jobs) {
|
|
136
|
+
export function createBraidTools(jobs, blocking = () => false) {
|
|
130
137
|
const braidTool = defineTool({
|
|
131
138
|
name: "braid",
|
|
132
139
|
label: "Braid",
|
|
133
140
|
description: "Use this tool FIRST for nontrivial engineering work: code reviews, bug investigations, design comparisons, test planning, and changes spanning multiple files. " +
|
|
134
|
-
"
|
|
141
|
+
"Run a mutable graph of isolated LLM invocations with parallel branches and joins; the user does not need to mention Braid. " +
|
|
135
142
|
"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. " +
|
|
136
143
|
"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. " +
|
|
137
144
|
'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. ' +
|
|
@@ -141,8 +148,8 @@ export function createBraidTools(jobs) {
|
|
|
141
148
|
"no parent history, inherited extension/MCP tools, or recursive Braid tools. " +
|
|
142
149
|
BRAID_FILESYSTEM_GUIDANCE + " " +
|
|
143
150
|
"Do not use it for a simple one-step answer or trivial direct edit. " +
|
|
144
|
-
"Use
|
|
145
|
-
"
|
|
151
|
+
"Use braid_cancel(jobId) to stop it. Humans can open /braid for the live flow panel. " +
|
|
152
|
+
"Follow the current session's Braid execution policy in the system prompt for waiting, result retrieval, and pause/notification controls. " +
|
|
146
153
|
"Read result.status: failed graphs can still return successful terminal outputs.",
|
|
147
154
|
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",
|
|
148
155
|
promptGuidelines: [
|
|
@@ -167,36 +174,56 @@ export function createBraidTools(jobs) {
|
|
|
167
174
|
const pending = options.isPartial && !ctx.isError;
|
|
168
175
|
return new Text(theme.fg(pending ? "warning" : "error", pending ? fallback || "Braid submission pending…" : `✗ ${fallback || "Braid submission failed without error details"}`), 0, 0);
|
|
169
176
|
}
|
|
170
|
-
|
|
177
|
+
if (job.status !== "running")
|
|
178
|
+
return statusTool.renderResult(result, options, theme, { ...ctx, args: { jobId: job.jobId } });
|
|
179
|
+
return new Text(theme.fg("accent", `Braid ${options.isPartial ? "job" : "background job"} ${job.handle ?? job.jobId} · ${job.status} · /braid to view${job.error ? `\n${job.error}` : ""}`), 0, 0);
|
|
171
180
|
},
|
|
172
|
-
async execute(_toolCallId, params, signal,
|
|
181
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
173
182
|
signal?.throwIfAborted();
|
|
183
|
+
const waitForCompletion = blocking();
|
|
184
|
+
let job;
|
|
174
185
|
try {
|
|
175
|
-
|
|
186
|
+
job = jobs.start({
|
|
176
187
|
goal: params.goal,
|
|
177
188
|
nodes: params.nodes,
|
|
178
189
|
edges: params.edges,
|
|
179
190
|
...(params.loops !== undefined ? { loops: params.loops } : {}),
|
|
180
191
|
...(params.promptTemplates !== undefined ? { promptTemplates: params.promptTemplates } : {}),
|
|
181
|
-
}, params.options ?? {}, ctx);
|
|
192
|
+
}, params.options ?? {}, ctx, waitForCompletion);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
throw new Error(`Braid submission failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
196
|
+
}
|
|
197
|
+
if (!waitForCompletion)
|
|
182
198
|
return {
|
|
183
|
-
content: [
|
|
184
|
-
{
|
|
185
|
-
type: "text",
|
|
186
|
-
text: JSON.stringify({
|
|
187
|
-
jobId: job.handle,
|
|
188
|
-
canonicalJobId: job.jobId,
|
|
189
|
-
status: job.status,
|
|
190
|
-
graph: graphReceipt(job),
|
|
191
|
-
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.",
|
|
192
|
-
}),
|
|
193
|
-
},
|
|
194
|
-
],
|
|
199
|
+
content: [{ type: "text", text: formatReceipt(job, `${BRAID_WAIT_REMINDER} Humans can follow it live in /braid.`) }],
|
|
195
200
|
details: job,
|
|
196
201
|
};
|
|
202
|
+
const cancel = () => { jobs.cancel(job.jobId); };
|
|
203
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
204
|
+
try {
|
|
205
|
+
try {
|
|
206
|
+
onUpdate?.({
|
|
207
|
+
content: [{ type: "text", text: formatReceipt(job, "Waiting for the graph and workspace cleanup. Humans can follow it live in /braid.") }],
|
|
208
|
+
details: job,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch { /* Progress observers must not stop the foreground wait. */ }
|
|
212
|
+
await jobs.wait(job.jobId);
|
|
213
|
+
return await statusTool.execute(_toolCallId, { jobId: job.jobId }, signal, undefined, ctx);
|
|
197
214
|
}
|
|
198
215
|
catch (error) {
|
|
199
|
-
|
|
216
|
+
const current = jobs.get(job.jobId);
|
|
217
|
+
const usage = current.status === "running" || signal?.aborted ? undefined : jobs.claimUsage(job.jobId);
|
|
218
|
+
return {
|
|
219
|
+
content: [{ type: "text", text: `Braid job ${job.handle} was started and is ${current.status}. Could not wait for or retrieve its results: ${error instanceof Error ? error.message : String(error)}. Do not resubmit this graph: it may already have changed the checkout. Retrieve the existing job with braid_status(${JSON.stringify({ jobId: job.handle })}).` }],
|
|
220
|
+
details: current,
|
|
221
|
+
isError: true,
|
|
222
|
+
...(usage ? { usage } : {}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
signal?.removeEventListener("abort", cancel);
|
|
200
227
|
}
|
|
201
228
|
},
|
|
202
229
|
});
|
|
@@ -209,7 +236,7 @@ export function createBraidTools(jobs) {
|
|
|
209
236
|
const statusTool = defineTool({
|
|
210
237
|
name: "braid_status",
|
|
211
238
|
label: "Braid status",
|
|
212
|
-
description: "Retrieve a
|
|
239
|
+
description: "Retrieve a Braid job's status, node progress, and final results by exact session handle (e.g. job-1) or UUID in jobId. Add executionId for an exact execution, or nodeId for the latest instance, including intermediate results while the job runs. Focused reads include live activity: phase, time in phase and since the last observed signal, model request/stream state, running tools, and recent history. Lack of observed activity is a diagnostic signal, not proof of a stall. Every job read starts with the current graph revision and paused execution IDs for live control. Results are text; long ones are truncated and the full text is saved to a file whose path is shown. Usage is Pi's provider accounting, including rounds from failed nodes. Prefer the short handle from submission/reminders. Omit all IDs to list jobs in this session. Follow the current session's Braid execution policy for result retrieval; do not sleep, use a timer, or poll in a loop to wait for a job.",
|
|
213
240
|
parameters: statusParameters,
|
|
214
241
|
renderResult(result, options, theme, ctx) {
|
|
215
242
|
const job = result.details;
|
|
@@ -242,14 +269,14 @@ export function createBraidTools(jobs) {
|
|
|
242
269
|
...(job.fullOutputPath ? { fullOutputPath: job.fullOutputPath } : {}),
|
|
243
270
|
}, options.expanded, options.isPartial, theme, fallback);
|
|
244
271
|
},
|
|
245
|
-
async execute(_id, params) {
|
|
272
|
+
async execute(_id, params, signal) {
|
|
246
273
|
if ((params.nodeId !== undefined || params.executionId !== undefined) && !params.jobId)
|
|
247
274
|
throw new Error("nodeId/executionId requires jobId");
|
|
248
275
|
if (params.activityBefore !== undefined && params.nodeId === undefined && params.executionId === undefined)
|
|
249
276
|
throw new Error("activityBefore requires nodeId or executionId");
|
|
250
277
|
if (!params.jobId)
|
|
251
278
|
return {
|
|
252
|
-
content: [{ type: "text", text:
|
|
279
|
+
content: [{ type: "text", text: formatJobList(jobs.list()) }],
|
|
253
280
|
details: undefined,
|
|
254
281
|
};
|
|
255
282
|
const job = jobs.get(params.jobId);
|
|
@@ -257,63 +284,22 @@ export function createBraidTools(jobs) {
|
|
|
257
284
|
throw jobs.unknownJob(params.jobId);
|
|
258
285
|
if (params.nodeId !== undefined || params.executionId !== undefined) {
|
|
259
286
|
const node = jobs.getNode(params.jobId, params.nodeId, params.executionId);
|
|
260
|
-
const
|
|
261
|
-
const activity = executionId ? jobs.getActivity(params.jobId, executionId, {
|
|
287
|
+
const activity = node.executionId ? jobs.getActivity(params.jobId, node.executionId, {
|
|
262
288
|
limit: STATUS_ACTIVITY_ENTRIES, ...(params.activityBefore === undefined ? {} : { before: params.activityBefore }),
|
|
263
289
|
}) : undefined;
|
|
264
|
-
|
|
265
|
-
// so it comes first and history cannot push it out of the preview.
|
|
266
|
-
const running = activity && activity.finishedAt === undefined;
|
|
267
|
-
const live = activity ? { observedAt: job.live.observedAt, activity } : {};
|
|
268
|
-
const full = JSON.stringify({
|
|
269
|
-
jobId: job.jobId, handle: job.handle, status: job.status, execution: executionControl(job),
|
|
270
|
-
...(running ? live : {}),
|
|
271
|
-
node: { id, executionId, status, error, output, ...nodeDetails },
|
|
272
|
-
...(running ? {} : live),
|
|
273
|
-
}, null, 2);
|
|
274
|
-
const preview = truncateHead(full);
|
|
275
|
-
let suffix = "";
|
|
276
|
-
let nodeOutputPath;
|
|
277
|
-
if (preview.truncated) {
|
|
278
|
-
const directory = await mkdtemp(join(tmpdir(), "braid-node-result-"));
|
|
279
|
-
const path = join(directory, "result.json");
|
|
280
|
-
await writeFile(path, full, { mode: 0o600 });
|
|
281
|
-
nodeOutputPath = path;
|
|
282
|
-
suffix = `\n[Preview truncated. Full node result: ${path}]`;
|
|
283
|
-
}
|
|
290
|
+
const result = await boundedText(formatNodeStatus({ job, node, ...(activity ? { activity } : {}), now: Date.now() }), "node.txt");
|
|
284
291
|
// Focused reads do not claim the whole job's usage; final job retrieval does.
|
|
285
|
-
return { content: [{ type: "text", text:
|
|
286
|
-
...job, selectedNode: node, ...(activity ? { selectedActivity: activity } : {}), ...(
|
|
292
|
+
return { content: [{ type: "text", text: result.text }], details: {
|
|
293
|
+
...job, selectedNode: node, ...(activity ? { selectedActivity: activity } : {}), ...(result.path ? { nodeOutputPath: result.path } : {}),
|
|
287
294
|
} };
|
|
288
295
|
}
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
const full = JSON.stringify({
|
|
292
|
-
jobId, handle, status,
|
|
293
|
-
error: error ?? job.result?.error ?? execution?.error,
|
|
294
|
-
usage: providerUsage, fullOutputPath,
|
|
295
|
-
execution: execution && { ...executionControl(job), ...execution },
|
|
296
|
-
...snapshot,
|
|
297
|
-
}, null, 2);
|
|
298
|
-
const preview = truncateHead(full);
|
|
299
|
-
let suffix = "";
|
|
300
|
-
let details = job;
|
|
301
|
-
if (preview.truncated) {
|
|
302
|
-
if (job.fullOutputPath)
|
|
303
|
-
suffix = `\n[Preview truncated. Full result/log: ${job.fullOutputPath}]`;
|
|
304
|
-
else {
|
|
305
|
-
const directory = await mkdtemp(join(tmpdir(), "braid-status-"));
|
|
306
|
-
const path = join(directory, "status.json");
|
|
307
|
-
await writeFile(path, full, { mode: 0o600 });
|
|
308
|
-
details = { ...job, fullOutputPath: path };
|
|
309
|
-
suffix = `\n[Preview truncated. Full status snapshot: ${path}]`;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
296
|
+
const result = await boundedText(formatJobStatus(job, Date.now()), "status.txt");
|
|
297
|
+
const details = result.path ? { ...job, fullOutputPath: result.path } : job;
|
|
312
298
|
// Saving a running snapshot can race job completion; claim usage only
|
|
313
299
|
// when this response actually contains a terminal snapshot.
|
|
314
|
-
const usage = job.status === "running" ? undefined : jobs.claimUsage(job.jobId);
|
|
300
|
+
const usage = job.status === "running" || signal?.aborted ? undefined : jobs.claimUsage(job.jobId);
|
|
315
301
|
return {
|
|
316
|
-
content: [{ type: "text", text:
|
|
302
|
+
content: [{ type: "text", text: result.text }],
|
|
317
303
|
details,
|
|
318
304
|
...(usage ? { usage } : {}),
|
|
319
305
|
};
|
|
@@ -342,14 +328,14 @@ export function createBraidTools(jobs) {
|
|
|
342
328
|
});
|
|
343
329
|
const updateParameters = Type.Object({
|
|
344
330
|
jobId: text("Exact session handle (e.g. job-1) or UUID."),
|
|
345
|
-
expectedRevision: Type.Integer({ minimum: 0, description: "Current
|
|
331
|
+
expectedRevision: Type.Integer({ minimum: 0, description: "Current graph revision from braid_status. A stale revision rejects the entire update." }),
|
|
346
332
|
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." })),
|
|
347
333
|
removeNodeIds: Type.Optional(Type.Array(text(), { description: "Node IDs to remove, together with all incident edges." })),
|
|
348
334
|
addEdges: Type.Optional(Type.Array(updateEdgeParameters, { description: "Edges to add. Pin a historical source using executionId when needed." })),
|
|
349
|
-
removeEdges: Type.Optional(Type.Array(updateEdgeParameters, { description: "Exact edge identities to remove: copy from, to, and every present choice/feedback/executionId from
|
|
335
|
+
removeEdges: Type.Optional(Type.Array(updateEdgeParameters, { description: "Exact edge identities to remove: copy from, to, and every present choice/feedback/executionId from the current graph edges in braid_status. Omitted optional fields do not act as wildcards." })),
|
|
350
336
|
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.` })),
|
|
351
337
|
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)." })),
|
|
352
|
-
resume: Type.Optional(Type.Array(text(), { uniqueItems: true, description: "Paused execution IDs from
|
|
338
|
+
resume: Type.Optional(Type.Array(text(), { uniqueItems: true, description: "Paused execution IDs from braid_status, not node IDs. Releases them atomically with the graph edit." })),
|
|
353
339
|
}, { additionalProperties: false });
|
|
354
340
|
const updateTool = defineTool({
|
|
355
341
|
name: "braid_update", label: "Update Braid",
|
|
@@ -364,13 +350,13 @@ export function createBraidTools(jobs) {
|
|
|
364
350
|
catch (error) {
|
|
365
351
|
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.`);
|
|
366
352
|
}
|
|
367
|
-
return { content: [{ type: "text", text:
|
|
353
|
+
return { content: [{ type: "text", text: formatUpdate(job) }], details: job };
|
|
368
354
|
},
|
|
369
355
|
});
|
|
370
356
|
const resumeParameters = Type.Object({
|
|
371
357
|
jobId: text("Exact session handle (e.g. job-1) or UUID."),
|
|
372
|
-
expectedRevision: Type.Integer({ minimum: 0, description: "Current
|
|
373
|
-
executionIds: Type.Array(text(), { minItems: 1, uniqueItems: true, description: "IDs from
|
|
358
|
+
expectedRevision: Type.Integer({ minimum: 0, description: "Current graph revision from braid_status." }),
|
|
359
|
+
executionIds: Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Paused execution IDs from braid_status, not node IDs. Only these completed, paused executions are released." }),
|
|
374
360
|
}, { additionalProperties: false });
|
|
375
361
|
const resumeTool = defineTool({
|
|
376
362
|
name: "braid_resume", label: "Resume Braid",
|
|
@@ -378,12 +364,30 @@ export function createBraidTools(jobs) {
|
|
|
378
364
|
parameters: resumeParameters,
|
|
379
365
|
async execute(_id, params) {
|
|
380
366
|
const job = jobs.resume(params.jobId, params.executionIds, params.expectedRevision);
|
|
381
|
-
return { content: [{ type: "text", text:
|
|
367
|
+
return { content: [{ type: "text", text: formatResume(job) }], details: job };
|
|
382
368
|
},
|
|
383
369
|
});
|
|
384
370
|
return { braidTool, statusTool, cancelTool, updateTool, resumeTool };
|
|
385
371
|
}
|
|
386
372
|
export default function braidExtension(pi) {
|
|
373
|
+
pi.registerFlag("braid-blocking", {
|
|
374
|
+
description: "Experimental: wait for braid tool calls to finish in the foreground (for CI/print mode)",
|
|
375
|
+
type: "boolean",
|
|
376
|
+
default: false,
|
|
377
|
+
});
|
|
378
|
+
let sessionBlocking;
|
|
379
|
+
const blocking = () => sessionBlocking ?? (pi.getFlag("braid-blocking") === true);
|
|
380
|
+
pi.registerCommand("braid:blocking", {
|
|
381
|
+
description: "Experimental: set blocking mode for new braid tool calls: /braid:blocking [on|off]; omit to show the current mode",
|
|
382
|
+
handler: async (args, ctx) => {
|
|
383
|
+
const setting = args.trim().toLowerCase();
|
|
384
|
+
if (setting === "on" || setting === "off")
|
|
385
|
+
sessionBlocking = setting === "on";
|
|
386
|
+
else if (setting)
|
|
387
|
+
throw new Error("Usage: /braid:blocking [on|off]");
|
|
388
|
+
ctx.ui.notify(`Braid blocking mode: ${blocking() ? "on" : "off"}. Applies to new braid tool calls.`, "info");
|
|
389
|
+
},
|
|
390
|
+
});
|
|
387
391
|
const pending = new Map();
|
|
388
392
|
const remind = (message) => {
|
|
389
393
|
try {
|
|
@@ -436,7 +440,7 @@ export default function braidExtension(pi) {
|
|
|
436
440
|
remind(message);
|
|
437
441
|
}
|
|
438
442
|
});
|
|
439
|
-
const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs);
|
|
443
|
+
const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs, blocking);
|
|
440
444
|
registerBraidCommand(pi, jobs);
|
|
441
445
|
registerReviewCommand(pi, jobs);
|
|
442
446
|
const disposeWidget = registerBraidWidget(pi, jobs);
|
|
@@ -451,6 +455,6 @@ export default function braidExtension(pi) {
|
|
|
451
455
|
disposeWidget();
|
|
452
456
|
});
|
|
453
457
|
pi.on("before_agent_start", (event) => ({
|
|
454
|
-
systemPrompt: `${event.systemPrompt}\n\n## Braid execution policy\n${BRAID_USAGE_GUIDANCE}\n\nFor the current user request, make this delegation choice before using read, grep, find, edit, write, or bash. Completion reminders refer to existing jobs: retrieve their results instead of submitting the same graph again.`,
|
|
458
|
+
systemPrompt: `${event.systemPrompt}\n\n## Braid execution policy\n${BRAID_USAGE_GUIDANCE}\n${blocking() ? "Blocking mode is enabled for this session. " + BRAID_BLOCKING_GUIDANCE : BRAID_ASYNC_GUIDANCE}\n\nFor the current user request, make this delegation choice before using read, grep, find, edit, write, or bash. Completion reminders refer to existing jobs: retrieve their results instead of submitting the same graph again.`,
|
|
455
459
|
}));
|
|
456
460
|
}
|
package/dist/jobs.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
1
|
import { setImmediate as nextTurn } from "node:timers/promises";
|
|
5
|
-
import { truncateHead, } from "@earendil-works/pi-coding-agent";
|
|
6
2
|
import { ExecutionActivityTracker, startBraid, validateGraph, } from "@chrok/braid";
|
|
7
3
|
import { applyEvent, applyProgress, createLiveState, } from "./display.js";
|
|
8
4
|
import { createPiRunner, sumPiUsage } from "./runner.js";
|
|
@@ -40,10 +36,12 @@ export class BraidJobs {
|
|
|
40
36
|
}
|
|
41
37
|
}
|
|
42
38
|
}
|
|
43
|
-
start(input, options, ctx) {
|
|
39
|
+
start(input, options, ctx, blocking = false) {
|
|
44
40
|
if (this.disposed)
|
|
45
41
|
throw new Error("Braid session has closed");
|
|
46
42
|
validateGraph(input);
|
|
43
|
+
if (blocking && input.nodes.some(node => node.pauseAfter))
|
|
44
|
+
throw new Error("Blocking Braid jobs cannot use pauseAfter; remove it or disable blocking with /braid:blocking off.");
|
|
47
45
|
const snapshot = structuredClone(input);
|
|
48
46
|
const settings = { ...options };
|
|
49
47
|
const model = ctx.model
|
|
@@ -53,6 +51,7 @@ export class BraidJobs {
|
|
|
53
51
|
const cwd = ctx.cwd;
|
|
54
52
|
this.cwd = cwd;
|
|
55
53
|
const job = {
|
|
54
|
+
blocking,
|
|
56
55
|
handle: `job-${this.nextHandle++}`,
|
|
57
56
|
jobId: crypto.randomUUID(),
|
|
58
57
|
goal: snapshot.goal,
|
|
@@ -77,7 +76,7 @@ export class BraidJobs {
|
|
|
77
76
|
async run(job, input, options, registry, cwd, model) {
|
|
78
77
|
const reports = [];
|
|
79
78
|
try {
|
|
80
|
-
//
|
|
79
|
+
// Defer provider work until the job handle has been published.
|
|
81
80
|
job.run = startBraid(input, {
|
|
82
81
|
...options,
|
|
83
82
|
cwd,
|
|
@@ -154,7 +153,7 @@ export class BraidJobs {
|
|
|
154
153
|
const shouldNotify = event.type === "execution_paused" ||
|
|
155
154
|
((event.type === "node_completed" || event.type === "node_failed") && instance?.node.notifyOnCompletion &&
|
|
156
155
|
(!instance.node.pauseAfter || state?.error));
|
|
157
|
-
if (!this.disposed && shouldNotify && "nodeId" in event && instance) {
|
|
156
|
+
if (!this.disposed && !job.blocking && shouldNotify && "nodeId" in event && instance) {
|
|
158
157
|
try {
|
|
159
158
|
void Promise.resolve(this.onNodeFinished({
|
|
160
159
|
jobId: job.jobId,
|
|
@@ -182,16 +181,6 @@ export class BraidJobs {
|
|
|
182
181
|
const status = job.result.error?.code === "CANCELLED"
|
|
183
182
|
? "cancelled"
|
|
184
183
|
: job.result.status;
|
|
185
|
-
// Core metadata counts usage returned by runners; Pi also records completed
|
|
186
|
-
// provider rounds from workers that later fail or time out.
|
|
187
|
-
const full = JSON.stringify({ ...job.result, workspaces: job.workspaces, piUsage: job.usage }, null, 2);
|
|
188
|
-
const preview = JSON.stringify({ ...this.get(job.jobId), status }, null, 2);
|
|
189
|
-
if (truncateHead(preview).truncated) {
|
|
190
|
-
const directory = await mkdtemp(join(tmpdir(), "braid-result-"));
|
|
191
|
-
const path = join(directory, "result.json");
|
|
192
|
-
await writeFile(path, full, { mode: 0o600 });
|
|
193
|
-
job.fullOutputPath = path;
|
|
194
|
-
}
|
|
195
184
|
job.status = status;
|
|
196
185
|
}
|
|
197
186
|
catch (error) {
|
|
@@ -204,7 +193,7 @@ export class BraidJobs {
|
|
|
204
193
|
job.usage = sumPiUsage(reports);
|
|
205
194
|
}
|
|
206
195
|
this.changed();
|
|
207
|
-
if (!this.disposed) {
|
|
196
|
+
if (!this.disposed && (!job.blocking || job.status === "cancelled")) {
|
|
208
197
|
try {
|
|
209
198
|
this.onFinished(this.get(job.jobId));
|
|
210
199
|
}
|
|
@@ -224,7 +213,7 @@ export class BraidJobs {
|
|
|
224
213
|
const job = this.lookup(jobId);
|
|
225
214
|
if (!job)
|
|
226
215
|
return undefined;
|
|
227
|
-
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, humanInputs: _humanInputs, ...snapshot } = job;
|
|
216
|
+
const { blocking: _blocking, run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, humanInputs: _humanInputs, ...snapshot } = job;
|
|
228
217
|
const copy = structuredClone(snapshot);
|
|
229
218
|
if (job.run)
|
|
230
219
|
copy.execution = job.run.snapshot();
|
package/dist/runner.js
CHANGED
|
@@ -5,6 +5,18 @@ import { createWorktreeWriteTools } from "./write-tools.js";
|
|
|
5
5
|
import { createAvailableReadTools } from "./read-tools.js";
|
|
6
6
|
import { createWorkspaceShellTools } from "./shell-tools.js";
|
|
7
7
|
import { formatHumanInput } from "./human-input.js";
|
|
8
|
+
/**
|
|
9
|
+
* Present a Git result the way Pi's bash tool presents command output. Output
|
|
10
|
+
* stays byte-exact, so a diff can be passed back to git apply.
|
|
11
|
+
*/
|
|
12
|
+
export function formatGitResult(result) {
|
|
13
|
+
let output = result.stdout;
|
|
14
|
+
if (result.stderr)
|
|
15
|
+
output += (output && !output.endsWith("\n") ? "\n" : "") + result.stderr;
|
|
16
|
+
if (result.exitCode === 0)
|
|
17
|
+
return output || "(no output)";
|
|
18
|
+
return `${output ? `${output.replace(/\n$/, "")}\n\n` : ""}Command exited with code ${result.exitCode}`;
|
|
19
|
+
}
|
|
8
20
|
/** Keep Pi's provider/auth plumbing and filesystem capabilities out of Braid's core. */
|
|
9
21
|
export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd()) {
|
|
10
22
|
const options = typeof onUsageOrOptions === "function"
|
|
@@ -289,7 +301,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
289
301
|
if (call.name === "git" && request.git) {
|
|
290
302
|
const args = parseGitToolArguments(call.arguments, (request.node.type === "merge" || request.node.type === "integrate"));
|
|
291
303
|
const result = await request.git(args.args, args.input);
|
|
292
|
-
return toolResult(call,
|
|
304
|
+
return toolResult(call, formatGitResult(result), result.exitCode !== 0);
|
|
293
305
|
}
|
|
294
306
|
if (call.name === "finish_merge" && request.merge) {
|
|
295
307
|
const dispositions = parseFinishMergeArguments(call.arguments, request.merge.sources.map(source => source.executionId));
|
|
@@ -339,7 +351,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
|
|
|
339
351
|
// No coercion or prose parsing: the core enforces the enum and exactly one call.
|
|
340
352
|
request.decide(args.choice);
|
|
341
353
|
decided = true;
|
|
342
|
-
return toolResult(call,
|
|
354
|
+
return toolResult(call, `Recorded choice: ${args.choice}`, false);
|
|
343
355
|
};
|
|
344
356
|
while (true) {
|
|
345
357
|
const response = await complete();
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** Model-facing text documents for the parent Braid tools. */
|
|
2
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
3
|
+
const duration = (ms) => ms < 1000 ? `${Math.max(0, Math.round(ms))}ms` : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
|
4
|
+
const failure = (error) => `${error.code}: ${error.message}`;
|
|
5
|
+
const ids = (values) => values?.length ? values.join(", ") : "none";
|
|
6
|
+
const GOAL_CHARS = 2000;
|
|
7
|
+
const clip = (text, limit) => text.length > limit ? `${text.slice(0, limit)}… [${text.length} chars]` : text;
|
|
8
|
+
const indent = (text) => text.split("\n").map(line => line ? ` ${line}` : line).join("\n");
|
|
9
|
+
export function formatUsage(usage) {
|
|
10
|
+
if (!usage)
|
|
11
|
+
return undefined;
|
|
12
|
+
const parts = [`input ${usage.input}`, `output ${usage.output}`];
|
|
13
|
+
if (usage.cacheRead)
|
|
14
|
+
parts.push(`cache read ${usage.cacheRead}`);
|
|
15
|
+
if (usage.cacheWrite)
|
|
16
|
+
parts.push(`cache write ${usage.cacheWrite}`);
|
|
17
|
+
const cost = usage.cost?.total ? `, $${usage.cost.total.toFixed(4)}` : "";
|
|
18
|
+
return `${parts.join(", ")} (${usage.totalTokens} tokens${cost})`;
|
|
19
|
+
}
|
|
20
|
+
function nodeLine(node) {
|
|
21
|
+
const settings = [node.type];
|
|
22
|
+
if ("workspace" in node && node.workspace)
|
|
23
|
+
settings.push(`workspace=${node.workspace}`);
|
|
24
|
+
if ("choices" in node)
|
|
25
|
+
settings.push(`choices=${node.choices.join("|")}`);
|
|
26
|
+
if (node.model)
|
|
27
|
+
settings.push(`model=${node.model}`);
|
|
28
|
+
if (typeof node.prompt === "object")
|
|
29
|
+
settings.push(`template=${node.prompt.template}`);
|
|
30
|
+
for (const flag of ["pauseAfter", "notifyOnCompletion", "requireSuccess"])
|
|
31
|
+
if (node[flag])
|
|
32
|
+
settings.push(flag);
|
|
33
|
+
return `- ${node.id}: ${settings.join(", ")}`;
|
|
34
|
+
}
|
|
35
|
+
/** Accepted definitions without prompts, which the caller already sent. */
|
|
36
|
+
export function formatGraph(graph) {
|
|
37
|
+
const lines = ["Nodes:", ...graph.nodes.map(nodeLine), "Edges:"];
|
|
38
|
+
lines.push(...graph.edges.length ? graph.edges.map(edge => {
|
|
39
|
+
const labels = [edge.choice && `choice=${edge.choice}`, edge.feedback && `feedback=${edge.feedback}`, edge.executionId && `execution=${edge.executionId}`].filter(Boolean);
|
|
40
|
+
return `- ${edge.from} -> ${edge.to}${labels.length ? ` [${labels.join(", ")}]` : ""}`;
|
|
41
|
+
}) : ["- none"]);
|
|
42
|
+
if (graph.loops?.length)
|
|
43
|
+
lines.push("Loops:", ...graph.loops.map(loop => `- ${loop.id}: entry ${loop.entry}, at most ${loop.maxIterations} iterations`));
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
function workspaceLine(workspace) {
|
|
47
|
+
const parts = [`${workspace.mode} ${workspace.workingDirectory}`, workspace.state];
|
|
48
|
+
for (const root of [workspace.worktreeRoot, workspace.sourceRoot])
|
|
49
|
+
if (root && root !== workspace.workingDirectory)
|
|
50
|
+
parts.push(`root ${root}`);
|
|
51
|
+
if (workspace.checkpointRef)
|
|
52
|
+
parts.push(`checkpoint ${workspace.checkpointRef}`);
|
|
53
|
+
if (workspace.backupRef)
|
|
54
|
+
parts.push(`backup ${workspace.backupRef}`);
|
|
55
|
+
if (workspace.reason)
|
|
56
|
+
parts.push(workspace.reason);
|
|
57
|
+
for (const item of workspace.dispositions ?? [])
|
|
58
|
+
parts.push(`${item.executionId} ${item.disposition}: ${item.reason}`);
|
|
59
|
+
return parts.join(" · ");
|
|
60
|
+
}
|
|
61
|
+
function executionLine(execution) {
|
|
62
|
+
const parts = [execution.executionId ?? "(no execution)", `node ${execution.id}`];
|
|
63
|
+
if (execution.iteration)
|
|
64
|
+
parts.push(`iteration ${execution.iteration}`);
|
|
65
|
+
parts.push(execution.skipReason ? `${execution.status} (${execution.skipReason})` : execution.status);
|
|
66
|
+
if (execution.latencyMs !== undefined)
|
|
67
|
+
parts.push(duration(execution.latencyMs));
|
|
68
|
+
if (execution.decision)
|
|
69
|
+
parts.push(`decision ${execution.decision}`);
|
|
70
|
+
if (execution.error)
|
|
71
|
+
parts.push(failure(execution.error));
|
|
72
|
+
return `- ${parts.join(" · ")}`;
|
|
73
|
+
}
|
|
74
|
+
export function formatJobList(jobs) {
|
|
75
|
+
if (!jobs.length)
|
|
76
|
+
return "No Braid jobs in this session.";
|
|
77
|
+
return [`${jobs.length} Braid job${jobs.length === 1 ? "" : "s"} in this session, newest first:`,
|
|
78
|
+
...jobs.map(job => `- ${job.handle} · ${job.status} · started ${iso(job.createdAt)} · id ${job.jobId}\n Goal: ${job.goal}`)].join("\n");
|
|
79
|
+
}
|
|
80
|
+
function control(execution) {
|
|
81
|
+
return execution ? `Graph ${execution.status} · revision ${execution.revision} · paused executions: ${ids(execution.pausedExecutionIds)}` : "Graph not started";
|
|
82
|
+
}
|
|
83
|
+
/** The submission receipt. The caller already has the graph it submitted. */
|
|
84
|
+
export function formatReceipt(job, message) {
|
|
85
|
+
return [`Braid job ${job.handle} started (${job.status}).`, `ID: ${job.jobId}`, message].join("\n");
|
|
86
|
+
}
|
|
87
|
+
export function formatUpdate(job) {
|
|
88
|
+
return [`Braid job ${job.handle} updated to revision ${job.execution.revision}.`,
|
|
89
|
+
`Paused executions: ${ids(job.execution.pausedExecutionIds)}`, "Current graph:", formatGraph(job.execution.graph)].join("\n");
|
|
90
|
+
}
|
|
91
|
+
export function formatResume(job) {
|
|
92
|
+
return `Braid job ${job.handle} resumed at revision ${job.execution.revision}. Still paused: ${ids(job.execution.pausedExecutionIds)}.`;
|
|
93
|
+
}
|
|
94
|
+
export function formatActivity(activity, now, jobId) {
|
|
95
|
+
const lines = [`Activity: ${activity.phase} for ${duration(now - activity.phaseStartedAt)}; last observed signal ${duration(now - activity.lastActivityAt)} ago (${activity.lastActivity}). Model requests ${activity.modelRequests}, tool calls ${activity.toolCalls}.`];
|
|
96
|
+
const model = activity.model;
|
|
97
|
+
if (model && model.completedAt === undefined) {
|
|
98
|
+
const stream = model.firstStreamAt === undefined ? "no stream received yet"
|
|
99
|
+
: `receiving ${model.receiving ?? "output"}${model.toolName ? ` for ${model.toolName}` : ""}, ${model.receivedChars} chars, last chunk ${duration(now - (model.lastStreamAt ?? model.firstStreamAt))} ago`;
|
|
100
|
+
lines.push(`Model request ${model.round}: started ${duration(now - model.startedAt)} ago; ${stream}.`);
|
|
101
|
+
}
|
|
102
|
+
for (const tool of activity.tools) {
|
|
103
|
+
lines.push(`Running tool ${tool.name} for ${duration(now - tool.startedAt)}: ${tool.arguments}`);
|
|
104
|
+
if (tool.outputTail)
|
|
105
|
+
lines.push(indent(`Output tail:\n${tool.outputTail}`));
|
|
106
|
+
}
|
|
107
|
+
for (const limitation of activity.limitations)
|
|
108
|
+
lines.push(`Not observable: ${limitation}`);
|
|
109
|
+
if (activity.entries.length) {
|
|
110
|
+
lines.push("History:");
|
|
111
|
+
for (const entry of activity.entries) {
|
|
112
|
+
lines.push(`- #${entry.sequence} ${iso(entry.timestamp)} ${entry.kind}${entry.toolName ? ` ${entry.toolName}` : ""}${entry.isError ? " (error)" : ""}${entry.durationMs !== undefined ? ` ${duration(entry.durationMs)}` : ""}: ${entry.summary}`);
|
|
113
|
+
if (entry.detail)
|
|
114
|
+
lines.push(indent(entry.detail + (entry.detailLength ? `\n[preview of ${entry.detailLength} chars]` : "")));
|
|
115
|
+
}
|
|
116
|
+
const first = activity.entries[0].sequence;
|
|
117
|
+
if (first > 1)
|
|
118
|
+
lines.push(first - 1 > activity.droppedEntries
|
|
119
|
+
? `Earlier entries: braid_status({"jobId":"${jobId}","executionId":"${activity.executionId}","activityBefore":${first}})`
|
|
120
|
+
: `${activity.droppedEntries} earlier entries are no longer retained.`);
|
|
121
|
+
}
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
124
|
+
/** A focused read: the current control state, then one execution in full. */
|
|
125
|
+
export function formatNodeStatus({ job, node, activity, now }) {
|
|
126
|
+
const lines = [`Braid job ${job.handle} · ${job.status} · ${control(job.execution)}`,
|
|
127
|
+
`Execution ${node.executionId ?? "(none yet)"} · node ${node.id}${node.node ? ` (${node.node.type})` : ""}${node.iteration ? ` · iteration ${node.iteration}` : ""} · ${node.skipReason ? `${node.status} (${node.skipReason})` : node.status}`];
|
|
128
|
+
const timing = [node.startedAt !== undefined && `started ${iso(node.startedAt)}`, node.latencyMs !== undefined && `took ${duration(node.latencyMs)}`,
|
|
129
|
+
node.model && `model ${node.model}`, node.usage && `usage input ${node.usage.inputTokens}, output ${node.usage.outputTokens}`].filter(Boolean);
|
|
130
|
+
if (timing.length)
|
|
131
|
+
lines.push(timing.join(" · "));
|
|
132
|
+
if (node.decision)
|
|
133
|
+
lines.push(`Decision: ${node.decision}`);
|
|
134
|
+
if (node.error)
|
|
135
|
+
lines.push(`Error: ${failure(node.error)}`);
|
|
136
|
+
if (node.workspace)
|
|
137
|
+
lines.push(`Workspace: ${workspaceLine(node.workspace)}`);
|
|
138
|
+
if (node.humanInputs?.length)
|
|
139
|
+
lines.push("Human input delivered during this execution:", ...node.humanInputs.map(input => `- ${iso(input.timestamp)}: ${input.text}`));
|
|
140
|
+
const output = `Output:\n${node.output ?? "(none)"}`;
|
|
141
|
+
// While running, activity is the useful part; afterwards the output is.
|
|
142
|
+
const running = activity && activity.finishedAt === undefined;
|
|
143
|
+
const sections = [lines.join("\n"), ...running ? [formatActivity(activity, now, job.handle), output] : [output, ...activity ? [formatActivity(activity, now, job.handle)] : []]];
|
|
144
|
+
return sections.join("\n\n");
|
|
145
|
+
}
|
|
146
|
+
/** The whole job: control state, every execution, then their outputs and the current definition. */
|
|
147
|
+
export function formatJobStatus(job, now) {
|
|
148
|
+
const state = job.result ?? job.execution;
|
|
149
|
+
// Control fields lead: a long goal or output must not push them out of a truncated preview.
|
|
150
|
+
const header = [`Braid job ${job.handle} · ${job.status}`, `ID: ${job.jobId}`, control(job.execution)];
|
|
151
|
+
const error = job.error ?? (job.result?.error ? failure(job.result.error) : job.execution?.error ? failure(job.execution.error) : undefined);
|
|
152
|
+
if (error)
|
|
153
|
+
header.push(`Error: ${error}`);
|
|
154
|
+
const usage = formatUsage(job.usage);
|
|
155
|
+
if (usage)
|
|
156
|
+
header.push(`Usage: ${usage}`);
|
|
157
|
+
if (job.result)
|
|
158
|
+
header.push(`Finished ${iso(job.result.metadata.finishedAt)} after ${duration(job.result.metadata.latencyMs)}`);
|
|
159
|
+
else
|
|
160
|
+
header.push(`Started ${iso(job.createdAt)}, ${duration(now - job.createdAt)} ago`);
|
|
161
|
+
header.push(`Goal: ${clip(job.goal, GOAL_CHARS)}`);
|
|
162
|
+
const sections = [header.join("\n")];
|
|
163
|
+
const executions = Object.values(state?.executions ?? {});
|
|
164
|
+
if (executions.length)
|
|
165
|
+
sections.push([`Executions (${executions.length}):`, ...executions.map(executionLine)].join("\n"));
|
|
166
|
+
const pending = Object.values(state?.nodes ?? {}).filter(node => !node.executionId);
|
|
167
|
+
if (pending.length)
|
|
168
|
+
sections.push(["Nodes without executions:", ...pending.map(executionLine)].join("\n"));
|
|
169
|
+
const terminal = new Set(job.result?.terminalExecutionIds ?? []);
|
|
170
|
+
const outputs = executions.filter(execution => execution.output !== undefined);
|
|
171
|
+
if (outputs.length)
|
|
172
|
+
sections.push(["Outputs:", ...outputs.map(execution => `## ${execution.id} · ${execution.executionId}${terminal.has(execution.executionId) ? " · terminal" : ""}${execution.decision ? ` · decision ${execution.decision}` : ""}\n${execution.output}`)].join("\n\n"));
|
|
173
|
+
const workspaces = executions.filter(execution => execution.workspace);
|
|
174
|
+
if (workspaces.length)
|
|
175
|
+
sections.push(["Workspaces:", ...workspaces.map(execution => `- ${execution.executionId}: ${workspaceLine(execution.workspace)}`)].join("\n"));
|
|
176
|
+
if (job.execution)
|
|
177
|
+
sections.push(`Current graph (revision ${job.execution.revision}):\n${formatGraph(job.execution.graph)}`);
|
|
178
|
+
return sections.join("\n\n");
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrok/pi-braid",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Pi extension for Braid: background agent graphs, bounded loops, live updates, and a flow panel",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"grok-mermaid": "^0.2.2",
|
|
9
|
-
"@chrok/braid": "0.3.
|
|
9
|
+
"@chrok/braid": "0.3.6"
|
|
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": "1.0
|
|
18
|
-
"@earendil-works/pi-tui": "1.0
|
|
19
|
-
"@earendil-works/pi-coding-agent": "1.0
|
|
17
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
18
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
19
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
20
20
|
"typescript": "^5.0.0",
|
|
21
21
|
"tsx": "^4.0.0",
|
|
22
22
|
"@types/node": "^22.0.0"
|