@chrok/pi-braid 0.3.6 → 0.5.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/dist/drain.js ADDED
@@ -0,0 +1,171 @@
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 and paused jobs are not resumed automatically: resume or cancel them before ending your turn. If only paused work remains when you end a turn, you get one warning; ending the next turn without resuming it cancels those 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 waitingOn = (job) => [
20
+ job.pausedExecutionIds.length ? `paused executions ${JSON.stringify(job.pausedExecutionIds)}` : "",
21
+ job.suspendedExecutionIds.length ? `suspended executions ${JSON.stringify(job.suspendedExecutionIds)}` +
22
+ (job.timedOutExecutionIds?.length ? ` (paused by their node timeout: ${JSON.stringify(job.timedOutExecutionIds)})` : "") : "",
23
+ job.paused ? "its job pause" : "",
24
+ ].filter(Boolean).join(" and ") || "paused work";
25
+ const stalledReminder = (jobs) => `[system-reminder] This single-shot run cannot end while Braid jobs are outstanding, and nothing can progress: ` +
26
+ jobs.map(job => `${job.handle} is waiting on ${waitingOn(job)} at revision ${job.revision}`).join("; ") +
27
+ `. Resume executions with braid_resume or braid_update with resume, resume a paused job with braid_resume({jobId}), fail single executions with braid_cancel and executionIds, or cancel the job with braid_cancel, then continue the original task. ` +
28
+ `If you end your turn again without doing so, Braid cancels these jobs and the run exits with a failure status. [/system-reminder]`;
29
+ /**
30
+ * Keeps print and JSON runs alive until every Braid job reaches a terminal
31
+ * status. Reminders are steered into the run as in interactive mode, so the
32
+ * parent keeps the async model: it ends its turn and the reminder resumes it.
33
+ */
34
+ export function registerDrain(pi, jobs, hasPendingReminders) {
35
+ pi.registerFlag("braid-ci-timeout", {
36
+ 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",
37
+ type: "string",
38
+ });
39
+ let closed = false;
40
+ let timer;
41
+ let timedOut = false;
42
+ let warnedStall;
43
+ const failures = new Set();
44
+ let outcome;
45
+ let reported;
46
+ const fail = (reason) => {
47
+ if (failures.has(reason))
48
+ return;
49
+ failures.add(reason);
50
+ process.exitCode = 1;
51
+ console.error(`Braid: ${reason}`);
52
+ };
53
+ const cancelOutstanding = (reason) => {
54
+ const outstanding = jobs.outstanding();
55
+ if (!outstanding.length)
56
+ return;
57
+ fail(reason);
58
+ for (const job of outstanding)
59
+ jobs.cancel(job.jobId);
60
+ };
61
+ const expire = () => {
62
+ timedOut = true;
63
+ cancelOutstanding("--braid-ci-timeout expired; cancelled outstanding Braid jobs");
64
+ };
65
+ const nextChange = () => new Promise(resolve => {
66
+ const done = () => {
67
+ clearTimeout(tick);
68
+ unsubscribe();
69
+ resolve();
70
+ };
71
+ const tick = setTimeout(done, RECHECK_MS);
72
+ const unsubscribe = jobs.subscribe(done);
73
+ });
74
+ const finishParameters = Type.Object({
75
+ status: StringEnum(["success", "failure"], { description: "Whether the run achieved its task. failure makes the process exit 1." }),
76
+ summary: Type.String({ minLength: 1, description: "Short summary of the outcome for CI logs and JSON output." }),
77
+ }, { additionalProperties: false });
78
+ pi.registerTool(defineTool({
79
+ name: "braid_finish",
80
+ label: "Finish Braid run",
81
+ description: "Single-shot (print/JSON) runs only: record the run outcome for CI. status failure makes the process exit 1. " +
82
+ "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.",
83
+ parameters: finishParameters,
84
+ // Activated in single-shot sessions only, so interactive runs never see it.
85
+ defaultActive: false,
86
+ async execute(_id, params, _signal, _onUpdate, ctx) {
87
+ if (!isSingleShot(ctx))
88
+ throw new Error("braid_finish is only available in print and JSON runs");
89
+ outcome = { status: params.status, summary: params.summary };
90
+ const outstanding = jobs.outstanding().length;
91
+ return {
92
+ content: [{
93
+ type: "text",
94
+ text: `Recorded ${params.status}.` + (outstanding
95
+ ? ` ${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.`
96
+ : " End your turn with your final response."),
97
+ }],
98
+ details: outcome,
99
+ };
100
+ },
101
+ }));
102
+ /** Applies the reported outcome once the run is about to settle with nothing outstanding. */
103
+ const settle = (event) => {
104
+ if (!outcome || outcome === reported)
105
+ return;
106
+ reported = outcome;
107
+ // Never reset the exit code: success must not mask a forced cancellation or a Pi error.
108
+ if (outcome.status === "failure") {
109
+ process.exitCode = 1;
110
+ console.error(`Braid: run reported failure: ${outcome.summary}`);
111
+ }
112
+ // A custom entry is not model context and leaves the final assistant message last,
113
+ // so text output is unchanged; JSON mode streams it as an entry_appended event.
114
+ return {
115
+ entries: [...event.entries, {
116
+ type: "custom",
117
+ customType: "braid-finish",
118
+ data: { ...outcome, forcedFailures: [...failures] },
119
+ }],
120
+ };
121
+ };
122
+ pi.on("session_start", (_event, ctx) => {
123
+ if (isSingleShot(ctx) && !pi.getActiveTools().includes("braid_finish"))
124
+ pi.setActiveTools([...pi.getActiveTools(), "braid_finish"]);
125
+ });
126
+ pi.on("before_agent_start", (_event, ctx) => {
127
+ const budget = pi.getFlag("braid-ci-timeout");
128
+ if (timer || timedOut || !isSingleShot(ctx) || typeof budget !== "string")
129
+ return;
130
+ // The budget covers the whole run, starting at its first prompt.
131
+ timer = setTimeout(expire, parseDuration(budget));
132
+ timer.unref();
133
+ });
134
+ pi.on("agent_before_settle", async (event, ctx) => {
135
+ if (!isSingleShot(ctx))
136
+ return;
137
+ while (!closed) {
138
+ // A queued reminder continues this run; Pi settles only when its queues are empty.
139
+ if (hasPendingReminders())
140
+ return;
141
+ if (timedOut)
142
+ cancelOutstanding("--braid-ci-timeout expired; cancelled outstanding Braid jobs");
143
+ const outstanding = jobs.outstanding();
144
+ if (!outstanding.length)
145
+ return settle(event);
146
+ if (outstanding.every(job => job.stalled)) {
147
+ const key = JSON.stringify(outstanding.map(job => [job.jobId, job.revision, job.pausedExecutionIds, job.suspendedExecutionIds, job.paused]));
148
+ if (key !== warnedStall) {
149
+ warnedStall = key;
150
+ return {
151
+ entries: [...event.entries, {
152
+ type: "custom_message",
153
+ customType: "braid-drain-stalled",
154
+ display: true,
155
+ content: stalledReminder(outstanding),
156
+ details: { jobs: outstanding },
157
+ }],
158
+ continue: true,
159
+ };
160
+ }
161
+ // Cancellation reminders give the parent a final turn to report the outcome.
162
+ cancelOutstanding(`paused work was never resumed; cancelled ${outstanding.map(job => job.handle).join(", ")}`);
163
+ }
164
+ await nextChange();
165
+ }
166
+ });
167
+ pi.on("session_shutdown", () => {
168
+ closed = true;
169
+ clearTimeout(timer);
170
+ });
171
+ }
package/dist/index.js CHANGED
@@ -7,8 +7,9 @@ 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
- import { formatJobList, formatJobStatus, formatNodeStatus, formatReceipt, formatResume, formatUpdate } from "./status-text.js";
12
+ import { formatCancelExecutions, formatHold, formatJobList, formatJobStatus, formatNodeStatus, formatPause, formatReceipt, formatResume, formatTimeoutPause, formatUpdate } from "./status-text.js";
12
13
  const text = (description) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
13
14
  const prompt = () => Type.Union([
14
15
  text("Instructions for this node; it does not receive the parent conversation."),
@@ -24,7 +25,12 @@ const timeout = (scope) => Type.Optional(Type.Number({
24
25
  maximum: 2_147_483_647,
25
26
  description: scope === "graph"
26
27
  ? "Total wall-clock timeout in milliseconds, including queueing and pauses. Updates/resume do not reset it; reserve time for final integration. Omit for no time limit."
27
- : "Timeout in milliseconds per node execution; omit for no time limit.",
28
+ : "Active time in milliseconds per node execution, excluding pauses; omit for no time limit. See onNodeTimeout for what happens when it runs out.",
29
+ }));
30
+ const resumeTimeout = Type.Optional(Type.Number({
31
+ exclusiveMinimum: 0,
32
+ maximum: 2_147_483_647,
33
+ description: "New node time budget in milliseconds for the suspended executions, counted from when each continues. Default: a node timeout pause gets the job's nodeTimeoutMs again; other pauses keep their remaining budget.",
28
34
  }));
29
35
  const toolBudget = (unit) => Type.Optional(Type.Integer({
30
36
  minimum: 1,
@@ -34,8 +40,8 @@ const toolBudget = (unit) => Type.Optional(Type.Integer({
34
40
  const commonNodeParameters = {
35
41
  id: text("Unique node definition ID; edges refer to this exact ID. This is not an executionId."),
36
42
  model: Type.Optional(text("Model override as provider/model-id; omit to use the parent model.")),
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." })),
43
+ notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Send an execution completion reminder; default false. Does not pause scheduling." })),
44
+ 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." })),
39
45
  requireSuccess: Type.Optional(Type.Boolean({ description: "If true, failure aborts the entire job and cancels running siblings. Default false: unconditional successors can recover." })),
40
46
  };
41
47
  const workspace = Type.Optional(StringEnum(["read-only", "worktree"], {
@@ -84,6 +90,8 @@ const braidParameters = Type.Object({
84
90
  maxConcurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneous node executions; default 4." })),
85
91
  maxExecutions: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Total execution limit across all iterations and updates; default 1000." })),
86
92
  nodeTimeoutMs: timeout("node"), graphTimeoutMs: timeout("graph"), maxToolRounds: toolBudget("rounds"), maxToolCalls: toolBudget("calls"),
93
+ onNodeTimeout: Type.Optional(StringEnum(["pause", "fail"], { description: "What a node timeout does. pause (default): interrupt the current step and suspend the execution with its work, and send you a reminder to resume it with a new time budget or cancel it. fail: the execution fails with NODE_TIMEOUT." })),
94
+ maxModelRetries: Type.Optional(Type.Integer({ minimum: 0, maximum: 10, description: "Retries per model request for transient provider errors such as rate limits, overload, and network failures. The node continues where it was; completed tool calls are not repeated. Default 3; 0 disables." })),
87
95
  }, { additionalProperties: false })),
88
96
  }, { additionalProperties: false });
89
97
  const BRAID_FILESYSTEM_GUIDANCE = "Every execute/decision activation gets a fresh worktree in Git, based on its predecessor execution checkpoint (roots use the initial job snapshot). Set workspace=read-only to disable writes. Multiple independent code snapshots require an explicit merge node. " +
@@ -91,18 +99,19 @@ const BRAID_FILESYSTEM_GUIDANCE = "Every execute/decision activation gets a fres
91
99
  "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
100
  "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
101
  /** 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. " +
102
+ 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
103
  "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
104
  "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
105
  "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
106
  "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.";
107
+ "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. " +
108
+ "Use braid_pause to stop running executions temporarily (each stops before its next model request and keeps its conversation and workspace), or the whole job; send them a message or adjust the graph, then resume. " +
109
+ "pauseAfter is captured when an execution starts; to review a running execution's result before downstream work starts, use braid_pause({jobId, executionIds, after: true}) instead, and braid_resume({jobId, executionIds, after: true}) removes that hold. " +
110
+ "The user can also pause and resume from /braid; you get a notice with your next turn. Do not resume work the user paused unless the user asks. " +
111
+ "When an execution exceeds its nodeTimeoutMs it is paused, not failed, and you get a reminder: resume it with braid_resume (optionally a new nodeTimeoutMs), or fail only that execution with braid_cancel and executionIds.";
100
112
  /** Short reminder repeated in every submission receipt. */
101
113
  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
114
  "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.";
106
115
  const BRAID_USAGE_GUIDANCE = [
107
116
  "Braid is a proactive execution primitive, not only a user-requested command.",
108
117
  "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.",
@@ -133,7 +142,7 @@ async function boundedText(text, name) {
133
142
  await writeFile(path, text, { mode: 0o600 });
134
143
  return { text: `${preview.content}\n[Truncated. Full text: ${path}]`, path };
135
144
  }
136
- export function createBraidTools(jobs, blocking = () => false) {
145
+ export function createBraidTools(jobs) {
137
146
  const braidTool = defineTool({
138
147
  name: "braid",
139
148
  label: "Braid",
@@ -158,7 +167,7 @@ export function createBraidTools(jobs, blocking = () => false) {
158
167
  "Do not use braid for simple one-step answers or trivial direct edits. Let writable nodes run shell commands and tests for their work; use merge nodes to combine and validate snapshots before explicit integration.",
159
168
  BRAID_FILESYSTEM_GUIDANCE,
160
169
  "Decision nodes additionally receive decide. Nodes cannot call recursive Braid.",
161
- "Tool and time budgets are unlimited by default. Set maxToolRounds, maxToolCalls, nodeTimeoutMs, or graphTimeoutMs in options to impose hard limits; nodes receive system reminders of their remaining budgets before each model call.",
170
+ "Tool and time budgets are unlimited by default. Set maxToolRounds, maxToolCalls, nodeTimeoutMs, or graphTimeoutMs in options to impose hard limits; nodes receive system reminders of their remaining budgets before each model call. By default a node timeout pauses the execution instead of failing it, and you get a reminder to resume or cancel it; set onNodeTimeout=fail to fail it instead.",
162
171
  ],
163
172
  parameters: braidParameters,
164
173
  renderCall(args, theme) {
@@ -178,9 +187,8 @@ export function createBraidTools(jobs, blocking = () => false) {
178
187
  return statusTool.renderResult(result, options, theme, { ...ctx, args: { jobId: job.jobId } });
179
188
  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);
180
189
  },
181
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
190
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
182
191
  signal?.throwIfAborted();
183
- const waitForCompletion = blocking();
184
192
  let job;
185
193
  try {
186
194
  job = jobs.start({
@@ -189,42 +197,17 @@ export function createBraidTools(jobs, blocking = () => false) {
189
197
  edges: params.edges,
190
198
  ...(params.loops !== undefined ? { loops: params.loops } : {}),
191
199
  ...(params.promptTemplates !== undefined ? { promptTemplates: params.promptTemplates } : {}),
192
- }, params.options ?? {}, ctx, waitForCompletion);
200
+ },
201
+ // StringEnum is typed as string; the schema has already checked the value.
202
+ (params.options ?? {}), ctx);
193
203
  }
194
204
  catch (error) {
195
205
  throw new Error(`Braid submission failed: ${error instanceof Error ? error.message : String(error)}`);
196
206
  }
197
- if (!waitForCompletion)
198
- return {
199
- content: [{ type: "text", text: formatReceipt(job, `${BRAID_WAIT_REMINDER} Humans can follow it live in /braid.`) }],
200
- details: job,
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);
214
- }
215
- catch (error) {
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);
227
- }
207
+ return {
208
+ content: [{ type: "text", text: formatReceipt(job, `${BRAID_WAIT_REMINDER} Humans can follow it live in /braid.`) }],
209
+ details: job,
210
+ };
228
211
  },
229
212
  });
230
213
  const statusParameters = Type.Object({
@@ -305,13 +288,20 @@ export function createBraidTools(jobs, blocking = () => false) {
305
288
  };
306
289
  },
307
290
  });
308
- const cancelParameters = Type.Object({ jobId: text("Exact session handle (e.g. job-1) or UUID returned by braid or braid_status.") }, { additionalProperties: false });
291
+ const cancelParameters = Type.Object({
292
+ jobId: text("Exact session handle (e.g. job-1) or UUID returned by braid or braid_status."),
293
+ executionIds: Type.Optional(Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Running or paused execution IDs from braid_status, not node IDs. Cancels only these executions; omit to cancel the whole job." })),
294
+ }, { additionalProperties: false });
309
295
  const cancelTool = defineTool({
310
296
  name: "braid_cancel",
311
297
  label: "Cancel Braid",
312
- description: "Cancel a background Braid job by jobId. Stopping the foreground response does not cancel background jobs.",
298
+ description: "Cancel a background Braid job by jobId. Stopping the foreground response does not cancel background jobs. With executionIds, fail only those running or paused executions and keep the job running: their successors follow the usual failure rules (unconditional edges receive the error; requireSuccess aborts the job). A node timeout pause fails with NODE_TIMEOUT, anything else with CANCELLED.",
313
299
  parameters: cancelParameters,
314
300
  async execute(_id, params) {
301
+ if (params.executionIds) {
302
+ const job = jobs.cancelExecutions(params.jobId, params.executionIds, "agent");
303
+ return { content: [{ type: "text", text: formatCancelExecutions(job, params.executionIds) }], details: job };
304
+ }
315
305
  const cancelled = jobs.cancel(params.jobId);
316
306
  return {
317
307
  content: [
@@ -335,7 +325,8 @@ export function createBraidTools(jobs, blocking = () => false) {
335
325
  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." })),
336
326
  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.` })),
337
327
  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)." })),
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." })),
328
+ resume: Type.Optional(Type.Array(text(), { uniqueItems: true, description: "Paused or suspended execution IDs from braid_status, not node IDs. Releases them atomically with the graph edit." })),
329
+ resumeOptions: Type.Optional(Type.Object({ nodeTimeoutMs: resumeTimeout }, { additionalProperties: false, description: "Requires resume. Applies to the suspended executions in resume." })),
339
330
  }, { additionalProperties: false });
340
331
  const updateTool = defineTool({
341
332
  name: "braid_update", label: "Update Braid",
@@ -355,39 +346,83 @@ export function createBraidTools(jobs, blocking = () => false) {
355
346
  });
356
347
  const resumeParameters = Type.Object({
357
348
  jobId: text("Exact session handle (e.g. job-1) or UUID."),
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." }),
349
+ expectedRevision: Type.Optional(Type.Integer({ minimum: 0, description: "Current graph revision from braid_status. Required with executionIds." })),
350
+ executionIds: Type.Optional(Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Paused (pauseAfter) or suspended execution IDs from braid_status, not node IDs. Omit to resume a paused job." })),
351
+ after: Type.Optional(Type.Boolean({ description: "true: remove the hold that braid_pause after=true or pauseAfter put on these still-running executions, so their results go downstream when they finish. Requires executionIds; expectedRevision is not needed. A result that is already held is released without after." })),
352
+ nodeTimeoutMs: resumeTimeout,
360
353
  }, { additionalProperties: false });
361
354
  const resumeTool = defineTool({
362
355
  name: "braid_resume", label: "Resume Braid",
363
- description: "Release specific paused execution IDs using expectedRevision from braid_status. Independent branches keep running during a pause; this does not reset deadlines or execution limits.",
356
+ description: "Release paused (pauseAfter) executions, or resume suspended ones in the same conversation, by execution ID with expectedRevision from braid_status. Omit executionIds to resume a job paused with braid_pause; executions you paused separately stay suspended. Independent branches keep running during a pause; this does not reset the graph deadline or execution limits. An execution paused by its node timeout gets the job's nodeTimeoutMs again, or nodeTimeoutMs if given; resuming the job does not resume it. With after=true it instead removes the hold on still-running executions. Do not resume work the user paused or held unless the user asks.",
364
357
  parameters: resumeParameters,
365
358
  async execute(_id, params) {
366
- const job = jobs.resume(params.jobId, params.executionIds, params.expectedRevision);
359
+ if (params.after) {
360
+ if (params.nodeTimeoutMs !== undefined)
361
+ throw new Error("nodeTimeoutMs cannot be combined with after");
362
+ const job = hold(params.jobId, params.executionIds, false);
363
+ return { content: [{ type: "text", text: formatHold(job, params.executionIds, false) }], details: job };
364
+ }
365
+ if (!params.executionIds) {
366
+ if (params.nodeTimeoutMs !== undefined)
367
+ throw new Error("nodeTimeoutMs requires executionIds");
368
+ return { content: [{ type: "text", text: formatResume(jobs.resumeJob(params.jobId)) }], details: jobs.get(params.jobId) };
369
+ }
370
+ if (params.expectedRevision === undefined)
371
+ throw new Error("expectedRevision is required with executionIds; read it from braid_status");
372
+ const job = jobs.resume(params.jobId, params.executionIds, params.expectedRevision, "agent", params.nodeTimeoutMs === undefined ? undefined : { nodeTimeoutMs: params.nodeTimeoutMs });
367
373
  return { content: [{ type: "text", text: formatResume(job) }], details: job };
368
374
  },
369
375
  });
370
- return { braidTool, statusTool, cancelTool, updateTool, resumeTool };
371
- }
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");
376
+ /** Holds and their removal apply to running executions only; explain the usual mistake. */
377
+ const hold = (jobId, executionIds, value) => {
378
+ if (!executionIds)
379
+ throw new Error("after=true requires executionIds of running executions; read them from braid_status");
380
+ try {
381
+ return jobs.holdAfter(jobId, executionIds, value, "agent");
382
+ }
383
+ catch (error) {
384
+ const message = error instanceof Error ? error.message : String(error);
385
+ if (!/running execution IDs/.test(message))
386
+ throw error;
387
+ throw new Error(`${message}. after=true applies only while an execution is still running; a finished result that is held is released with braid_resume({jobId, expectedRevision, executionIds}) without after.`);
388
+ }
389
+ };
390
+ const pauseParameters = Type.Object({
391
+ jobId: text("Exact session handle (e.g. job-1) or UUID."),
392
+ executionIds: Type.Optional(Type.Array(text(), { minItems: 1, uniqueItems: true, description: "Running execution IDs from braid_status, not node IDs. Omit to pause the whole job." })),
393
+ after: Type.Optional(Type.Boolean({ description: "true: do not suspend; let these running executions finish, then hold their results like pauseAfter, so downstream work waits for braid_resume. Use it to review a result that is still being produced. Requires executionIds; applies to these executions only, not their node definitions." })),
394
+ }, { additionalProperties: false });
395
+ const pauseTool = defineTool({
396
+ name: "braid_pause", label: "Pause Braid",
397
+ description: "Temporarily stop running executions without losing their work: each finishes its running tool, then stops before its next model request, keeping its conversation, workspace, and tool budgets, and releasing its concurrency slot. Omit executionIds to pause the whole job: no new executions start and running ones pause. Use it to inspect a node, correct it with a message, or stop spending while you adjust the graph. Resume with braid_resume. The node timeout stops while suspended; the graph timeout keeps running, so resume or cancel paused work. With after=true it does not suspend: each execution finishes, then its result is held until braid_resume.",
398
+ parameters: pauseParameters,
399
+ async execute(_id, params) {
400
+ if (params.after) {
401
+ const job = hold(params.jobId, params.executionIds, true);
402
+ return { content: [{ type: "text", text: formatHold(job, params.executionIds, true) }], details: job };
403
+ }
404
+ const job = jobs.pause(params.jobId, params.executionIds, "agent");
405
+ return { content: [{ type: "text", text: formatPause(job, params.executionIds) }], details: job };
389
406
  },
390
407
  });
408
+ return { braidTool, statusTool, cancelTool, updateTool, resumeTool, pauseTool };
409
+ }
410
+ /** One sentence for a pause, resume, or hold change the parent did not make. */
411
+ export function formatControlNotice(notice) {
412
+ const who = notice.source === "user" ? "The user" : `A ${notice.source} control`;
413
+ const target = notice.executions
414
+ ? notice.executions.map(execution => `node ${JSON.stringify(execution.nodeId)} execution ${execution.executionId}`).join(", ")
415
+ : "the whole job";
416
+ const lookup = JSON.stringify({ jobId: notice.handle, ...(notice.executions?.length === 1 ? { executionId: notice.executions[0].executionId } : {}) });
417
+ switch (notice.action) {
418
+ case "paused": return `${who} paused ${target} of Braid job ${notice.handle}. Paused work keeps its conversation and workspace. Do not resume it unless the user asks; check the current state with braid_status(${lookup}).`;
419
+ case "resumed": return `${who} resumed ${target} of Braid job ${notice.handle}; it continues where it stopped.`;
420
+ case "held": return `${who} set ${target} of Braid job ${notice.handle} to hold its result once it finishes; downstream work waits for a resume. Do not release it unless the user asks; check the current state with braid_status(${lookup}).`;
421
+ case "unheld": return `${who} removed the hold on ${target} of Braid job ${notice.handle}; its result goes downstream when it finishes.`;
422
+ case "cancelled": return `${who} cancelled ${target} of Braid job ${notice.handle}. It failed; its successors follow the usual failure rules. Check the current state with braid_status(${lookup}).`;
423
+ }
424
+ }
425
+ export default function braidExtension(pi) {
391
426
  const pending = new Map();
392
427
  const remind = (message) => {
393
428
  try {
@@ -399,6 +434,23 @@ export default function braidExtension(pi) {
399
434
  // Keep failed deliveries pending for the next settled retry.
400
435
  }
401
436
  };
437
+ // Pauses, resumes, and holds made by someone else wait for the parent's next turn instead of
438
+ // waking it: a woken parent could resume work the user deliberately paused.
439
+ const notices = new Map();
440
+ const noteControl = (notice) => {
441
+ for (const execution of notice.executions ?? [undefined]) {
442
+ // The latest change per execution or job wins; holds and pauses are tracked separately.
443
+ const kind = notice.action === "held" || notice.action === "unheld" ? "hold" : "pause";
444
+ notices.set(JSON.stringify([notice.jobId, execution?.executionId ?? "job", kind]), { ...notice, ...(execution ? { executions: [execution] } : {}) });
445
+ }
446
+ };
447
+ pi.on("before_agent_start", () => {
448
+ if (!notices.size)
449
+ return;
450
+ const lines = [...notices.values()].map(formatControlNotice);
451
+ notices.clear();
452
+ return { message: { customType: "braid-control", display: true, content: `[system-reminder] ${lines.join(" ")} [/system-reminder]`, details: { notices: lines } } };
453
+ });
402
454
  const jobs = new BraidJobs((job) => {
403
455
  const { jobId, handle, status } = job;
404
456
  const reminderId = JSON.stringify([jobId, "job"]);
@@ -422,13 +474,23 @@ export default function braidExtension(pi) {
422
474
  };
423
475
  pending.set(reminderId, message);
424
476
  remind(message);
477
+ }, noteControl, (pause) => {
478
+ const reminderId = JSON.stringify([pause.jobId, "timeout", pause.executionId, pause.eventSequence]);
479
+ const message = {
480
+ customType: "braid-node-timeout",
481
+ display: true,
482
+ content: `[system-reminder] ${formatTimeoutPause(pause)} [/system-reminder]`,
483
+ details: { ...pause, reminderId },
484
+ };
485
+ pending.set(reminderId, message);
486
+ remind(message);
425
487
  });
426
488
  // Foreground cancellation can discard queued steering. Retry only reminders
427
489
  // that never entered context, once Pi has settled and emptied its queues.
428
490
  pi.on("message_start", (event) => {
429
491
  const message = event.message;
430
492
  if (message.role === "custom" &&
431
- (message.customType === "braid-completed" || message.customType === "braid-node-completed")) {
493
+ (message.customType === "braid-completed" || message.customType === "braid-node-completed" || message.customType === "braid-node-timeout")) {
432
494
  const details = message.details;
433
495
  if (details?.reminderId)
434
496
  pending.delete(details.reminderId);
@@ -440,7 +502,8 @@ export default function braidExtension(pi) {
440
502
  remind(message);
441
503
  }
442
504
  });
443
- const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs, blocking);
505
+ const { braidTool, statusTool, cancelTool, updateTool, resumeTool, pauseTool } = createBraidTools(jobs);
506
+ registerDrain(pi, jobs, () => pending.size > 0);
444
507
  registerBraidCommand(pi, jobs);
445
508
  registerReviewCommand(pi, jobs);
446
509
  const disposeWidget = registerBraidWidget(pi, jobs);
@@ -449,12 +512,16 @@ export default function braidExtension(pi) {
449
512
  pi.registerTool(cancelTool);
450
513
  pi.registerTool(updateTool);
451
514
  pi.registerTool(resumeTool);
515
+ pi.registerTool(pauseTool);
452
516
  pi.on("session_shutdown", () => {
453
517
  pending.clear();
518
+ notices.clear();
454
519
  jobs.dispose();
455
520
  disposeWidget();
456
521
  });
457
- pi.on("before_agent_start", (event) => ({
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.`,
459
- }));
522
+ pi.on("before_agent_start", (event, ctx) => {
523
+ return {
524
+ systemPrompt: `${event.systemPrompt}\n\n## Braid execution policy\n${BRAID_USAGE_GUIDANCE}\n${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.`,
525
+ };
526
+ });
460
527
  }