@chrok/pi-braid 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,29 +2,37 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/%40chrok%2Fpi-braid?label=%40chrok%2Fpi-braid)](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
- **0.3 API:** The npm badge shows the published version; see
8
+ **0.4 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 [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).
10
+ Review the [0.3 → 0.4 migration guide](https://github.com/Epsirom/braid/blob/v0.4.0/docs/compatibility.md#migrating-from-03-to-04)
11
+ before upgrading: print and JSON runs wait for background jobs, Pi SDK sessions
12
+ in the default print mode drain jobs before `session.prompt()` returns, and
13
+ blocking mode is deprecated. Users coming from 0.2 also need the
14
+ [0.2 → 0.3 guide](https://github.com/Epsirom/braid/blob/v0.4.0/docs/compatibility.md#migrating-from-02-to-03),
15
+ and users coming from 0.1 the
16
+ [0.1 → 0.2 guide](https://github.com/Epsirom/braid/blob/v0.4.0/docs/compatibility.md#migrating-from-01-to-02).
15
17
  For the previous release, use the
16
- [0.2.1 guide](https://github.com/Epsirom/braid/blob/v0.2.1/integrations/pi/README.md).
18
+ [0.3.6 guide](https://github.com/Epsirom/braid/blob/v0.3.6/integrations/pi/README.md).
17
19
 
18
20
  It registers:
19
21
 
20
- - `braid` — submit a graph with optional bounded loops and immediately receive a `jobId`.
22
+ - `braid` — submit a graph with optional bounded loops; receive a `jobId`
23
+ immediately in background mode (print/JSON runs wait for it before exiting), or
24
+ final results in deprecated blocking mode.
21
25
  - `braid_status` — retrieve progress and results with `{ "jobId": "..." }`, or
22
26
  add `"executionId": "..."` for an exact invocation's full output/error.
23
27
  `"nodeId": "..."` selects that definition's latest invocation. Omit all IDs
24
28
  to list jobs in the current session.
25
29
  - `braid_update` / `braid_resume` — edit live definitions or release paused executions.
26
30
  - `braid_cancel` — cancel a job with `{ "jobId": "..." }`.
31
+ - `braid_finish` — print/JSON runs only: record the run outcome with
32
+ `{ "status": "success" | "failure", "summary": "..." }`.
27
33
  - `/braid [jobId]` — open a live flow panel in interactive Pi.
34
+ - `/braid:blocking [on|off]` — deprecated, removed in 0.5.0: enable or disable
35
+ blocking for new `braid` tool calls; omit the argument to show the current mode.
28
36
  - `/braid:review [target or focus]` — run a bounded parallel review/fix loop.
29
37
  - `/braid:clear` — clear the progress widget above the editor; run it again while
30
38
  the widget is empty to restore what was cleared.
@@ -50,6 +58,91 @@ work, or end its turn if it needs the result, and not to sleep, use a timer, or
50
58
  poll in a loop to wait for the job.
51
59
  Stopping the foreground response does not stop background jobs.
52
60
 
61
+ ### Single-shot and CI runs
62
+
63
+ In print mode (`pi -p`) and `--mode json`, background execution works unchanged:
64
+ the run stays open until every Braid job reaches a terminal status and its
65
+ reminder has been delivered, then exits after the parent's final response.
66
+
67
+ ```sh
68
+ pi -p "Use Braid to review the changes and summarize the results."
69
+ ```
70
+
71
+ The parent submits, ends its turn, and is resumed by the reminder as in
72
+ interactive Pi, so `pauseAfter`, `notifyOnCompletion`, `braid_update`, and
73
+ `braid_resume` keep working. Paused executions are never resumed automatically.
74
+ If the parent ends its turn while only paused executions remain, it receives one
75
+ warning naming them; ending the next turn without resuming them cancels their
76
+ jobs, gives the parent a final turn on the cancellation reminder, and exits 1.
77
+
78
+ `--braid-ci-timeout <duration>` bounds the whole run, starting at its first
79
+ prompt. It accepts milliseconds or an `s`, `m`, or `h` suffix (for example
80
+ `--braid-ci-timeout 30m`). On expiry, outstanding jobs are cancelled, the parent
81
+ gets a final turn on the cancellation reminders, and the run exits 1. Either
82
+ forced cancellation prints a `Braid:` line on stderr.
83
+
84
+ A failed graph does not by itself fail the run. Instead, the parent is told to
85
+ call `braid_finish` with `status` `success` or `failure` and a short `summary`
86
+ once the task is done. The tool only records the outcome; the run still ends
87
+ when the parent ends its turn with no Braid job outstanding, and a later call
88
+ replaces the earlier outcome. A final `failure` exits 1 and prints
89
+ `Braid: run reported failure: <summary>` on stderr. `success` never overrides a
90
+ non-zero exit from a forced cancellation or from Pi itself. If the parent never
91
+ calls it, the exit status is Pi's own apart from forced cancellations.
92
+
93
+ In `--mode json`, the final outcome is also written as a session entry, after the
94
+ `tool_execution_end` event of the call itself:
95
+
96
+ ```json
97
+ {"type":"entry_appended","entry":{"type":"custom","customType":"braid-finish","data":{"status":"failure","summary":"Review found 2 blocking issues.","forcedFailures":[]},...}}
98
+ ```
99
+
100
+ `forcedFailures` lists any forced cancellations, which fail the run whatever the
101
+ reported status. The entry is not part of model context, and text mode still
102
+ prints only the parent's final response. `braid_finish` is not offered in
103
+ interactive or RPC sessions.
104
+
105
+ Pi SDK sessions default to print mode too, so `session.prompt()` returns only
106
+ after outstanding Braid jobs drain. Hosts that stay running and want reminders
107
+ to start new runs after settling should bind with another mode, for example
108
+ `session.bindExtensions({ mode: "rpc" })`.
109
+
110
+ ### Blocking mode (deprecated)
111
+
112
+ > **Deprecated:** `--braid-blocking` and `/braid:blocking` will be removed in
113
+ > 0.5.0. Print and JSON runs now wait for background jobs (see above), which keeps
114
+ > `pauseAfter`, reminders, and live updates, and `braid_finish` sets the exit
115
+ > status. To migrate, drop `--braid-blocking` (or `/braid:blocking on`) from the
116
+ > command line ([#80](https://github.com/Epsirom/braid/issues/80)). Using either
117
+ > prints a one-time warning: on stderr in print/JSON mode, or as a UI notice.
118
+
119
+ Until then, to make each `braid` call wait in the foreground, enable blocking when starting Pi:
120
+
121
+ ```sh
122
+ pi --braid-blocking -p "Use Braid to review the changes and summarize the results."
123
+ ```
124
+
125
+ With `@chrok/pi-braid` installed, `--braid-blocking` makes every `braid` tool call
126
+ wait for the entire graph and workspace cleanup, then return final status, outputs,
127
+ and usage directly. The parent can summarize those results before `pi -p` exits.
128
+ It also works with `--mode json` for structured tool results. The CLI flag sets the
129
+ initial mode; within a session, use `/braid:blocking on` to enable it,
130
+ `/braid:blocking off` to disable it, or `/braid:blocking` to check the current mode.
131
+ Switching affects only new `braid` tool calls; running jobs keep their original
132
+ mode. The session override is not persisted. These are user controls, not model
133
+ tool parameters; the default remains background execution.
134
+ For CI pass/fail decisions, inspect the returned job status: a failed graph does
135
+ not by itself set Pi's process exit code.
136
+ Blocking calls emit an initial progress update with the job handle while waiting.
137
+ They suppress node/job completion reminders except for cancellation: stopping the
138
+ foreground response cancels their work, waits for cleanup, and sends a reminder
139
+ so the next turn can retrieve partial outputs and recovery checkpoints.
140
+ Wait/result retrieval errors include the existing job handle and any terminal
141
+ usage; retrieve that job with `braid_status` instead of submitting the graph again.
142
+ `pauseAfter: true` is rejected before starting a blocking job because it requires
143
+ a later resume call. The interactive
144
+ `/braid:review` command continues to use its background review workflow.
145
+
53
146
  Jobs live in memory for the current Pi session. Quitting, reloading extensions,
54
147
  or switching/forking sessions aborts outstanding work and suppresses its
55
148
  reminders. Job IDs cannot be retrieved after that lifecycle ends. They are not
@@ -602,9 +695,11 @@ fails that node cleanly and does not invoke another provider.
602
695
 
603
696
  The Pi extension has no node or graph time limit by default. Use `braid_cancel`
604
697
  or press `c` in the flow panel to abort running nodes and mark queued nodes
605
- `cancelled`. Escape stops the foreground response or closes the panel without
606
- cancelling jobs. To set a node or graph timeout, supply milliseconds in the
607
- submission tool input under `options`; each omitted timeout remains unlimited:
698
+ `cancelled`. In the default background mode, Escape stops the foreground response
699
+ or closes the panel without cancelling jobs. In blocking mode, stopping the
700
+ foreground response also cancels its Braid job. To set a node or graph timeout,
701
+ supply milliseconds in the submission tool input under `options`; each omitted
702
+ timeout remains unlimited:
608
703
 
609
704
  ```json
610
705
  {
package/dist/drain.js ADDED
@@ -0,0 +1,165 @@
1
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
2
+ import { defineTool, } from "@earendil-works/pi-coding-agent";
3
+ /** Pi exits once print and JSON runs settle, so Braid drains jobs before that. */
4
+ export const isSingleShot = (ctx) => ctx.mode === "print" || ctx.mode === "json";
5
+ export const BRAID_DRAIN_GUIDANCE = "This is a single-shot run. It stays open until every Braid job finishes and its reminder is delivered, so submit, end your turn, and continue from the reminder as usual. " +
6
+ "Paused executions are not resumed automatically: resume or cancel them before ending your turn. If only paused executions remain when you end a turn, you get one warning; ending the next turn without resuming them cancels their jobs and the run exits with a failure status.";
7
+ export const BRAID_FINISH_GUIDANCE = "When the task is done and no Braid job is outstanding, call braid_finish once with status success or failure and a short summary of the outcome, then end your turn with your final response. " +
8
+ "A failure status makes the run exit 1. braid_finish only records the outcome: the run still ends when your turn ends and every Braid job has finished. Call it again to replace the outcome if more work follows.";
9
+ /** The run's wall-clock budget is checked by a timer, so the drain also wakes periodically. */
10
+ const RECHECK_MS = 1_000;
11
+ export function parseDuration(value) {
12
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/.exec(value);
13
+ const scale = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
14
+ const ms = match ? Number(match[1]) * scale[(match[2] ?? "ms")] : NaN;
15
+ if (!(ms > 0) || ms > 2_147_483_647)
16
+ throw new Error(`Invalid --braid-ci-timeout ${JSON.stringify(value)}: use a positive duration such as 90000, 90s, 30m, or 2h`);
17
+ return ms;
18
+ }
19
+ const stalledReminder = (jobs) => `[system-reminder] This single-shot run cannot end while Braid jobs are outstanding, and nothing can progress: ` +
20
+ jobs.map(job => `${job.handle} is waiting on paused executions ${JSON.stringify(job.pausedExecutionIds)} at revision ${job.revision}`).join("; ") +
21
+ `. Resume them with braid_resume or braid_update with resume, or cancel the job with braid_cancel, then continue the original task. ` +
22
+ `If you end your turn again without doing so, Braid cancels these jobs and the run exits with a failure status. [/system-reminder]`;
23
+ /**
24
+ * Keeps print and JSON runs alive until every Braid job reaches a terminal
25
+ * status. Reminders are steered into the run as in interactive mode, so the
26
+ * parent keeps the async model: it ends its turn and the reminder resumes it.
27
+ */
28
+ export function registerDrain(pi, jobs, hasPendingReminders) {
29
+ pi.registerFlag("braid-ci-timeout", {
30
+ description: "Wall-clock budget for Braid jobs in print/JSON mode (e.g. 90000, 90s, 30m, 2h); on expiry, outstanding jobs are cancelled and the run exits 1",
31
+ type: "string",
32
+ });
33
+ let closed = false;
34
+ let timer;
35
+ let timedOut = false;
36
+ let warnedStall;
37
+ const failures = new Set();
38
+ let outcome;
39
+ let reported;
40
+ const fail = (reason) => {
41
+ if (failures.has(reason))
42
+ return;
43
+ failures.add(reason);
44
+ process.exitCode = 1;
45
+ console.error(`Braid: ${reason}`);
46
+ };
47
+ const cancelOutstanding = (reason) => {
48
+ const outstanding = jobs.outstanding();
49
+ if (!outstanding.length)
50
+ return;
51
+ fail(reason);
52
+ for (const job of outstanding)
53
+ jobs.cancel(job.jobId);
54
+ };
55
+ const expire = () => {
56
+ timedOut = true;
57
+ cancelOutstanding("--braid-ci-timeout expired; cancelled outstanding Braid jobs");
58
+ };
59
+ const nextChange = () => new Promise(resolve => {
60
+ const done = () => {
61
+ clearTimeout(tick);
62
+ unsubscribe();
63
+ resolve();
64
+ };
65
+ const tick = setTimeout(done, RECHECK_MS);
66
+ const unsubscribe = jobs.subscribe(done);
67
+ });
68
+ const finishParameters = Type.Object({
69
+ status: StringEnum(["success", "failure"], { description: "Whether the run achieved its task. failure makes the process exit 1." }),
70
+ summary: Type.String({ minLength: 1, description: "Short summary of the outcome for CI logs and JSON output." }),
71
+ }, { additionalProperties: false });
72
+ pi.registerTool(defineTool({
73
+ name: "braid_finish",
74
+ label: "Finish Braid run",
75
+ description: "Single-shot (print/JSON) runs only: record the run outcome for CI. status failure makes the process exit 1. " +
76
+ "It does not end the run; call it once the task is done and no Braid job is outstanding, then end your turn. A later call replaces the outcome.",
77
+ parameters: finishParameters,
78
+ // Activated in single-shot sessions only, so interactive runs never see it.
79
+ defaultActive: false,
80
+ async execute(_id, params, _signal, _onUpdate, ctx) {
81
+ if (!isSingleShot(ctx))
82
+ throw new Error("braid_finish is only available in print and JSON runs");
83
+ outcome = { status: params.status, summary: params.summary };
84
+ const outstanding = jobs.outstanding().length;
85
+ return {
86
+ content: [{
87
+ type: "text",
88
+ text: `Recorded ${params.status}.` + (outstanding
89
+ ? ` ${outstanding} Braid job(s) are still outstanding; the run stays open until they finish, so continue from their reminders and call braid_finish again if the outcome changes.`
90
+ : " End your turn with your final response."),
91
+ }],
92
+ details: outcome,
93
+ };
94
+ },
95
+ }));
96
+ /** Applies the reported outcome once the run is about to settle with nothing outstanding. */
97
+ const settle = (event) => {
98
+ if (!outcome || outcome === reported)
99
+ return;
100
+ reported = outcome;
101
+ // Never reset the exit code: success must not mask a forced cancellation or a Pi error.
102
+ if (outcome.status === "failure") {
103
+ process.exitCode = 1;
104
+ console.error(`Braid: run reported failure: ${outcome.summary}`);
105
+ }
106
+ // A custom entry is not model context and leaves the final assistant message last,
107
+ // so text output is unchanged; JSON mode streams it as an entry_appended event.
108
+ return {
109
+ entries: [...event.entries, {
110
+ type: "custom",
111
+ customType: "braid-finish",
112
+ data: { ...outcome, forcedFailures: [...failures] },
113
+ }],
114
+ };
115
+ };
116
+ pi.on("session_start", (_event, ctx) => {
117
+ if (isSingleShot(ctx) && !pi.getActiveTools().includes("braid_finish"))
118
+ pi.setActiveTools([...pi.getActiveTools(), "braid_finish"]);
119
+ });
120
+ pi.on("before_agent_start", (_event, ctx) => {
121
+ const budget = pi.getFlag("braid-ci-timeout");
122
+ if (timer || timedOut || !isSingleShot(ctx) || typeof budget !== "string")
123
+ return;
124
+ // The budget covers the whole run, starting at its first prompt.
125
+ timer = setTimeout(expire, parseDuration(budget));
126
+ timer.unref();
127
+ });
128
+ pi.on("agent_before_settle", async (event, ctx) => {
129
+ if (!isSingleShot(ctx))
130
+ return;
131
+ while (!closed) {
132
+ // A queued reminder continues this run; Pi settles only when its queues are empty.
133
+ if (hasPendingReminders())
134
+ return;
135
+ if (timedOut)
136
+ cancelOutstanding("--braid-ci-timeout expired; cancelled outstanding Braid jobs");
137
+ const outstanding = jobs.outstanding();
138
+ if (!outstanding.length)
139
+ return settle(event);
140
+ if (outstanding.every(job => job.stalled)) {
141
+ const key = JSON.stringify(outstanding.map(job => [job.jobId, job.revision, job.pausedExecutionIds]));
142
+ if (key !== warnedStall) {
143
+ warnedStall = key;
144
+ return {
145
+ entries: [...event.entries, {
146
+ type: "custom_message",
147
+ customType: "braid-drain-stalled",
148
+ display: true,
149
+ content: stalledReminder(outstanding),
150
+ details: { jobs: outstanding },
151
+ }],
152
+ continue: true,
153
+ };
154
+ }
155
+ // Cancellation reminders give the parent a final turn to report the outcome.
156
+ cancelOutstanding(`paused executions were never resumed; cancelled ${outstanding.map(job => job.handle).join(", ")}`);
157
+ }
158
+ await nextChange();
159
+ }
160
+ });
161
+ pi.on("session_shutdown", () => {
162
+ closed = true;
163
+ clearTimeout(timer);
164
+ });
165
+ }
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { Text } from "@earendil-works/pi-tui";
7
7
  import { BraidJobs } from "./jobs.js";
8
8
  import { registerBraidCommand, registerBraidWidget } from "./command.js";
9
9
  import { registerReviewCommand } from "./review.js";
10
+ import { BRAID_DRAIN_GUIDANCE, BRAID_FINISH_GUIDANCE, isSingleShot, registerDrain } from "./drain.js";
10
11
  import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js";
11
12
  import { formatJobList, formatJobStatus, formatNodeStatus, formatReceipt, formatResume, formatUpdate } from "./status-text.js";
12
13
  const text = (description) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
@@ -34,8 +35,8 @@ const toolBudget = (unit) => Type.Optional(Type.Integer({
34
35
  const commonNodeParameters = {
35
36
  id: text("Unique node definition ID; edges refer to this exact ID. This is not an executionId."),
36
37
  model: Type.Optional(text("Model override as provider/model-id; omit to use the parent model.")),
37
- notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Send an execution completion reminder; default false. Does not pause scheduling." })),
38
- 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." })),
38
+ notifyOnCompletion: Type.Optional(Type.Boolean({ description: "In background mode, send an execution completion reminder; default false. Does not pause scheduling." })),
39
+ 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." })),
39
40
  requireSuccess: Type.Optional(Type.Boolean({ description: "If true, failure aborts the entire job and cancels running siblings. Default false: unconditional successors can recover." })),
40
41
  };
41
42
  const workspace = Type.Optional(StringEnum(["read-only", "worktree"], {
@@ -91,22 +92,26 @@ const BRAID_FILESYSTEM_GUIDANCE = "Every execute/decision activation gets a fres
91
92
  "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. " +
92
93
  "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
94
  /** Lifecycle guidance shared by the system prompt and tool descriptions. */
94
- export const BRAID_ASYNC_GUIDANCE = "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
+ 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
96
  "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
97
  "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
98
  "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 results with braid_status and continue the original task. Between reminders, use braid_status only for deliberate progress checks or diagnosis.";
99
+ "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. " +
100
+ "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.";
101
+ export const BRAID_BLOCKING_DEPRECATION = "Blocking mode (--braid-blocking, /braid:blocking) is deprecated and will be removed in 0.5.0. " +
102
+ "Print and JSON runs (pi -p) now wait for background Braid jobs; call braid_finish to report the outcome.";
99
103
  /** Short reminder repeated in every submission receipt. */
100
104
  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. " +
101
105
  "Do not sleep or poll in a loop to wait for this job. Retrieve its results with braid_status after the reminder.";
106
+ 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. " +
107
+ "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. " +
108
+ "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.";
102
109
  const BRAID_USAGE_GUIDANCE = [
103
110
  "Braid is a proactive execution primitive, not only a user-requested command.",
104
111
  "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.",
105
112
  BRAID_FILESYSTEM_GUIDANCE,
106
113
  "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.",
107
- "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. Use braid_status with the jobId to retrieve terminal outputs before relying on them.",
108
- BRAID_ASYNC_GUIDANCE,
109
- "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.",
114
+ "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.",
110
115
  "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.",
111
116
  "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.",
112
117
  ].join("\n");
@@ -131,12 +136,12 @@ async function boundedText(text, name) {
131
136
  await writeFile(path, text, { mode: 0o600 });
132
137
  return { text: `${preview.content}\n[Truncated. Full text: ${path}]`, path };
133
138
  }
134
- export function createBraidTools(jobs) {
139
+ export function createBraidTools(jobs, blocking = () => false) {
135
140
  const braidTool = defineTool({
136
141
  name: "braid",
137
142
  label: "Braid",
138
143
  description: "Use this tool FIRST for nontrivial engineering work: code reviews, bug investigations, design comparisons, test planning, and changes spanning multiple files. " +
139
- "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. " +
144
+ "Run a mutable graph of isolated LLM invocations with parallel branches and joins; the user does not need to mention Braid. " +
140
145
  "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. " +
141
146
  "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. " +
142
147
  '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. ' +
@@ -147,8 +152,7 @@ export function createBraidTools(jobs) {
147
152
  BRAID_FILESYSTEM_GUIDANCE + " " +
148
153
  "Do not use it for a simple one-step answer or trivial direct edit. " +
149
154
  "Use braid_cancel(jobId) to stop it. Humans can open /braid for the live flow panel. " +
150
- BRAID_ASYNC_GUIDANCE + " " +
151
- "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. " +
155
+ "Follow the current session's Braid execution policy in the system prompt for waiting, result retrieval, and pause/notification controls. " +
152
156
  "Read result.status: failed graphs can still return successful terminal outputs.",
153
157
  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",
154
158
  promptGuidelines: [
@@ -173,30 +177,56 @@ export function createBraidTools(jobs) {
173
177
  const pending = options.isPartial && !ctx.isError;
174
178
  return new Text(theme.fg(pending ? "warning" : "error", pending ? fallback || "Braid submission pending…" : `✗ ${fallback || "Braid submission failed without error details"}`), 0, 0);
175
179
  }
176
- 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);
180
+ if (job.status !== "running")
181
+ return statusTool.renderResult(result, options, theme, { ...ctx, args: { jobId: job.jobId } });
182
+ 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);
177
183
  },
178
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
184
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
179
185
  signal?.throwIfAborted();
186
+ const waitForCompletion = blocking();
187
+ let job;
180
188
  try {
181
- const job = jobs.start({
189
+ job = jobs.start({
182
190
  goal: params.goal,
183
191
  nodes: params.nodes,
184
192
  edges: params.edges,
185
193
  ...(params.loops !== undefined ? { loops: params.loops } : {}),
186
194
  ...(params.promptTemplates !== undefined ? { promptTemplates: params.promptTemplates } : {}),
187
- }, params.options ?? {}, ctx);
195
+ }, params.options ?? {}, ctx, waitForCompletion);
196
+ }
197
+ catch (error) {
198
+ throw new Error(`Braid submission failed: ${error instanceof Error ? error.message : String(error)}`);
199
+ }
200
+ if (!waitForCompletion)
188
201
  return {
189
- content: [
190
- {
191
- type: "text",
192
- text: formatReceipt(job, `${BRAID_WAIT_REMINDER} Humans can follow it live in /braid.`),
193
- },
194
- ],
202
+ content: [{ type: "text", text: formatReceipt(job, `${BRAID_WAIT_REMINDER} Humans can follow it live in /braid.`) }],
195
203
  details: job,
196
204
  };
205
+ const cancel = () => { jobs.cancel(job.jobId); };
206
+ signal?.addEventListener("abort", cancel, { once: true });
207
+ try {
208
+ try {
209
+ onUpdate?.({
210
+ content: [{ type: "text", text: formatReceipt(job, "Waiting for the graph and workspace cleanup. Humans can follow it live in /braid.") }],
211
+ details: job,
212
+ });
213
+ }
214
+ catch { /* Progress observers must not stop the foreground wait. */ }
215
+ await jobs.wait(job.jobId);
216
+ return await statusTool.execute(_toolCallId, { jobId: job.jobId }, signal, undefined, ctx);
197
217
  }
198
218
  catch (error) {
199
- throw new Error(`Braid submission failed: ${error instanceof Error ? error.message : String(error)}`);
219
+ const current = jobs.get(job.jobId);
220
+ const usage = current.status === "running" || signal?.aborted ? undefined : jobs.claimUsage(job.jobId);
221
+ return {
222
+ 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 })}).` }],
223
+ details: current,
224
+ isError: true,
225
+ ...(usage ? { usage } : {}),
226
+ };
227
+ }
228
+ finally {
229
+ signal?.removeEventListener("abort", cancel);
200
230
  }
201
231
  },
202
232
  });
@@ -209,7 +239,7 @@ export function createBraidTools(jobs) {
209
239
  const statusTool = defineTool({
210
240
  name: "braid_status",
211
241
  label: "Braid status",
212
- 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. 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. Completion reminders arrive automatically: read results after the reminder, or deliberately for a progress check or diagnosis; do not sleep, use a timer, or poll in a loop to wait for a job.",
242
+ 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
243
  parameters: statusParameters,
214
244
  renderResult(result, options, theme, ctx) {
215
245
  const job = result.details;
@@ -242,7 +272,7 @@ export function createBraidTools(jobs) {
242
272
  ...(job.fullOutputPath ? { fullOutputPath: job.fullOutputPath } : {}),
243
273
  }, options.expanded, options.isPartial, theme, fallback);
244
274
  },
245
- async execute(_id, params) {
275
+ async execute(_id, params, signal) {
246
276
  if ((params.nodeId !== undefined || params.executionId !== undefined) && !params.jobId)
247
277
  throw new Error("nodeId/executionId requires jobId");
248
278
  if (params.activityBefore !== undefined && params.nodeId === undefined && params.executionId === undefined)
@@ -270,7 +300,7 @@ export function createBraidTools(jobs) {
270
300
  const details = result.path ? { ...job, fullOutputPath: result.path } : job;
271
301
  // Saving a running snapshot can race job completion; claim usage only
272
302
  // when this response actually contains a terminal snapshot.
273
- const usage = job.status === "running" ? undefined : jobs.claimUsage(job.jobId);
303
+ const usage = job.status === "running" || signal?.aborted ? undefined : jobs.claimUsage(job.jobId);
274
304
  return {
275
305
  content: [{ type: "text", text: result.text }],
276
306
  details,
@@ -343,6 +373,37 @@ export function createBraidTools(jobs) {
343
373
  return { braidTool, statusTool, cancelTool, updateTool, resumeTool };
344
374
  }
345
375
  export default function braidExtension(pi) {
376
+ pi.registerFlag("braid-blocking", {
377
+ description: "Deprecated, removed in 0.5.0: wait for braid tool calls to finish in the foreground. pi -p now waits for background jobs",
378
+ type: "boolean",
379
+ default: false,
380
+ });
381
+ let sessionBlocking;
382
+ const blocking = () => sessionBlocking ?? (pi.getFlag("braid-blocking") === true);
383
+ let warnedBlocking = false;
384
+ const warnBlocking = (ctx) => {
385
+ if (warnedBlocking)
386
+ return;
387
+ warnedBlocking = true;
388
+ // Print and JSON runs have no UI; stderr keeps JSON stdout parseable.
389
+ if (ctx.hasUI)
390
+ ctx.ui.notify(BRAID_BLOCKING_DEPRECATION, "warning");
391
+ else
392
+ console.error(`Braid: ${BRAID_BLOCKING_DEPRECATION}`);
393
+ };
394
+ pi.registerCommand("braid:blocking", {
395
+ description: "Deprecated, removed in 0.5.0: set blocking mode for new braid tool calls: /braid:blocking [on|off]; omit to show the current mode",
396
+ handler: async (args, ctx) => {
397
+ const setting = args.trim().toLowerCase();
398
+ if (setting === "on" || setting === "off")
399
+ sessionBlocking = setting === "on";
400
+ else if (setting)
401
+ throw new Error("Usage: /braid:blocking [on|off]");
402
+ if (setting === "on")
403
+ warnBlocking(ctx);
404
+ ctx.ui.notify(`Braid blocking mode: ${blocking() ? "on" : "off"}. Applies to new braid tool calls.`, "info");
405
+ },
406
+ });
346
407
  const pending = new Map();
347
408
  const remind = (message) => {
348
409
  try {
@@ -395,7 +456,8 @@ export default function braidExtension(pi) {
395
456
  remind(message);
396
457
  }
397
458
  });
398
- const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs);
459
+ const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs, blocking);
460
+ registerDrain(pi, jobs, () => pending.size > 0);
399
461
  registerBraidCommand(pi, jobs);
400
462
  registerReviewCommand(pi, jobs);
401
463
  const disposeWidget = registerBraidWidget(pi, jobs);
@@ -409,7 +471,11 @@ export default function braidExtension(pi) {
409
471
  jobs.dispose();
410
472
  disposeWidget();
411
473
  });
412
- pi.on("before_agent_start", (event) => ({
413
- 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.`,
414
- }));
474
+ pi.on("before_agent_start", (event, ctx) => {
475
+ if (pi.getFlag("braid-blocking") === true)
476
+ warnBlocking(ctx);
477
+ return {
478
+ 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}${isSingleShot(ctx) ? `\n${BRAID_DRAIN_GUIDANCE}\n${BRAID_FINISH_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.`,
479
+ };
480
+ });
415
481
  }
package/dist/jobs.js CHANGED
@@ -36,10 +36,12 @@ export class BraidJobs {
36
36
  }
37
37
  }
38
38
  }
39
- start(input, options, ctx) {
39
+ start(input, options, ctx, blocking = false) {
40
40
  if (this.disposed)
41
41
  throw new Error("Braid session has closed");
42
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.");
43
45
  const snapshot = structuredClone(input);
44
46
  const settings = { ...options };
45
47
  const model = ctx.model
@@ -49,6 +51,7 @@ export class BraidJobs {
49
51
  const cwd = ctx.cwd;
50
52
  this.cwd = cwd;
51
53
  const job = {
54
+ blocking,
52
55
  handle: `job-${this.nextHandle++}`,
53
56
  jobId: crypto.randomUUID(),
54
57
  goal: snapshot.goal,
@@ -73,7 +76,7 @@ export class BraidJobs {
73
76
  async run(job, input, options, registry, cwd, model) {
74
77
  const reports = [];
75
78
  try {
76
- // Return the submission to Pi before doing provider work or sending reminders.
79
+ // Defer provider work until the job handle has been published.
77
80
  job.run = startBraid(input, {
78
81
  ...options,
79
82
  cwd,
@@ -150,7 +153,7 @@ export class BraidJobs {
150
153
  const shouldNotify = event.type === "execution_paused" ||
151
154
  ((event.type === "node_completed" || event.type === "node_failed") && instance?.node.notifyOnCompletion &&
152
155
  (!instance.node.pauseAfter || state?.error));
153
- if (!this.disposed && shouldNotify && "nodeId" in event && instance) {
156
+ if (!this.disposed && !job.blocking && shouldNotify && "nodeId" in event && instance) {
154
157
  try {
155
158
  void Promise.resolve(this.onNodeFinished({
156
159
  jobId: job.jobId,
@@ -190,7 +193,7 @@ export class BraidJobs {
190
193
  job.usage = sumPiUsage(reports);
191
194
  }
192
195
  this.changed();
193
- if (!this.disposed) {
196
+ if (!this.disposed && (!job.blocking || job.status === "cancelled")) {
194
197
  try {
195
198
  this.onFinished(this.get(job.jobId));
196
199
  }
@@ -210,7 +213,7 @@ export class BraidJobs {
210
213
  const job = this.lookup(jobId);
211
214
  if (!job)
212
215
  return undefined;
213
- 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;
214
217
  const copy = structuredClone(snapshot);
215
218
  if (job.run)
216
219
  copy.execution = job.run.snapshot();
@@ -310,6 +313,23 @@ export class BraidJobs {
310
313
  }))
311
314
  .reverse();
312
315
  }
316
+ /**
317
+ * Jobs that have not reached a terminal status. A job is stalled when only
318
+ * paused executions remain, so it cannot progress without resume or cancel.
319
+ */
320
+ outstanding() {
321
+ return [...this.jobs.values()].filter(job => job.status === "running").map(job => {
322
+ // Skip the snapshot copy while executions are visibly running.
323
+ const state = job.runningExecutions.size ? undefined : job.run?.snapshot();
324
+ return {
325
+ jobId: job.jobId,
326
+ handle: job.handle,
327
+ stalled: state?.status === "waiting",
328
+ ...(state ? { revision: state.revision } : {}),
329
+ pausedExecutionIds: state?.pausedExecutionIds ?? [],
330
+ };
331
+ });
332
+ }
313
333
  /** Editor progress without copying outputs, transcripts, or execution snapshots. */
314
334
  progress(jobId) {
315
335
  const job = this.lookup(jobId);
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@chrok/pi-braid",
3
- "version": "0.3.5",
3
+ "version": "0.4.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.3.5"
9
+ "@chrok/braid": "0.4.0"
10
10
  },
11
11
  "peerDependencies": {
12
12
  "@earendil-works/pi-ai": "*",