@chrok/pi-braid 0.4.0 → 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/index.js CHANGED
@@ -9,7 +9,7 @@ import { registerBraidCommand, registerBraidWidget } from "./command.js";
9
9
  import { registerReviewCommand } from "./review.js";
10
10
  import { BRAID_DRAIN_GUIDANCE, BRAID_FINISH_GUIDANCE, isSingleShot, registerDrain } from "./drain.js";
11
11
  import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js";
12
- 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";
13
13
  const text = (description) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) });
14
14
  const prompt = () => Type.Union([
15
15
  text("Instructions for this node; it does not receive the parent conversation."),
@@ -25,7 +25,12 @@ const timeout = (scope) => Type.Optional(Type.Number({
25
25
  maximum: 2_147_483_647,
26
26
  description: scope === "graph"
27
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."
28
- : "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.",
29
34
  }));
30
35
  const toolBudget = (unit) => Type.Optional(Type.Integer({
31
36
  minimum: 1,
@@ -35,8 +40,8 @@ const toolBudget = (unit) => Type.Optional(Type.Integer({
35
40
  const commonNodeParameters = {
36
41
  id: text("Unique node definition ID; edges refer to this exact ID. This is not an executionId."),
37
42
  model: Type.Optional(text("Model override as provider/model-id; omit to use the parent model.")),
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." })),
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." })),
40
45
  requireSuccess: Type.Optional(Type.Boolean({ description: "If true, failure aborts the entire job and cancels running siblings. Default false: unconditional successors can recover." })),
41
46
  };
42
47
  const workspace = Type.Optional(StringEnum(["read-only", "worktree"], {
@@ -85,6 +90,8 @@ const braidParameters = Type.Object({
85
90
  maxConcurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneous node executions; default 4." })),
86
91
  maxExecutions: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Total execution limit across all iterations and updates; default 1000." })),
87
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." })),
88
95
  }, { additionalProperties: false })),
89
96
  }, { additionalProperties: false });
90
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. " +
@@ -92,20 +99,19 @@ const BRAID_FILESYSTEM_GUIDANCE = "Every execute/decision activation gets a fres
92
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. " +
93
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.";
94
101
  /** Lifecycle guidance shared by the system prompt and tool descriptions. */
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. " +
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. " +
96
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. " +
97
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. " +
98
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. " +
99
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. " +
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.";
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.";
103
112
  /** Short reminder repeated in every submission receipt. */
104
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. " +
105
114
  "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.";
109
115
  const BRAID_USAGE_GUIDANCE = [
110
116
  "Braid is a proactive execution primitive, not only a user-requested command.",
111
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.",
@@ -136,7 +142,7 @@ async function boundedText(text, name) {
136
142
  await writeFile(path, text, { mode: 0o600 });
137
143
  return { text: `${preview.content}\n[Truncated. Full text: ${path}]`, path };
138
144
  }
139
- export function createBraidTools(jobs, blocking = () => false) {
145
+ export function createBraidTools(jobs) {
140
146
  const braidTool = defineTool({
141
147
  name: "braid",
142
148
  label: "Braid",
@@ -161,7 +167,7 @@ export function createBraidTools(jobs, blocking = () => false) {
161
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.",
162
168
  BRAID_FILESYSTEM_GUIDANCE,
163
169
  "Decision nodes additionally receive decide. Nodes cannot call recursive Braid.",
164
- "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.",
165
171
  ],
166
172
  parameters: braidParameters,
167
173
  renderCall(args, theme) {
@@ -181,9 +187,8 @@ export function createBraidTools(jobs, blocking = () => false) {
181
187
  return statusTool.renderResult(result, options, theme, { ...ctx, args: { jobId: job.jobId } });
182
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);
183
189
  },
184
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
190
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
185
191
  signal?.throwIfAborted();
186
- const waitForCompletion = blocking();
187
192
  let job;
188
193
  try {
189
194
  job = jobs.start({
@@ -192,42 +197,17 @@ export function createBraidTools(jobs, blocking = () => false) {
192
197
  edges: params.edges,
193
198
  ...(params.loops !== undefined ? { loops: params.loops } : {}),
194
199
  ...(params.promptTemplates !== undefined ? { promptTemplates: params.promptTemplates } : {}),
195
- }, params.options ?? {}, ctx, waitForCompletion);
200
+ },
201
+ // StringEnum is typed as string; the schema has already checked the value.
202
+ (params.options ?? {}), ctx);
196
203
  }
197
204
  catch (error) {
198
205
  throw new Error(`Braid submission failed: ${error instanceof Error ? error.message : String(error)}`);
199
206
  }
200
- if (!waitForCompletion)
201
- return {
202
- content: [{ type: "text", text: formatReceipt(job, `${BRAID_WAIT_REMINDER} Humans can follow it live in /braid.`) }],
203
- details: job,
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);
217
- }
218
- catch (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);
230
- }
207
+ return {
208
+ content: [{ type: "text", text: formatReceipt(job, `${BRAID_WAIT_REMINDER} Humans can follow it live in /braid.`) }],
209
+ details: job,
210
+ };
231
211
  },
232
212
  });
233
213
  const statusParameters = Type.Object({
@@ -308,13 +288,20 @@ export function createBraidTools(jobs, blocking = () => false) {
308
288
  };
309
289
  },
310
290
  });
311
- 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 });
312
295
  const cancelTool = defineTool({
313
296
  name: "braid_cancel",
314
297
  label: "Cancel Braid",
315
- 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.",
316
299
  parameters: cancelParameters,
317
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
+ }
318
305
  const cancelled = jobs.cancel(params.jobId);
319
306
  return {
320
307
  content: [
@@ -338,7 +325,8 @@ export function createBraidTools(jobs, blocking = () => false) {
338
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." })),
339
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.` })),
340
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)." })),
341
- 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." })),
342
330
  }, { additionalProperties: false });
343
331
  const updateTool = defineTool({
344
332
  name: "braid_update", label: "Update Braid",
@@ -358,52 +346,83 @@ export function createBraidTools(jobs, blocking = () => false) {
358
346
  });
359
347
  const resumeParameters = Type.Object({
360
348
  jobId: text("Exact session handle (e.g. job-1) or UUID."),
361
- expectedRevision: Type.Integer({ minimum: 0, description: "Current graph revision from braid_status." }),
362
- 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,
363
353
  }, { additionalProperties: false });
364
354
  const resumeTool = defineTool({
365
355
  name: "braid_resume", label: "Resume Braid",
366
- 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.",
367
357
  parameters: resumeParameters,
368
358
  async execute(_id, params) {
369
- 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 });
370
373
  return { content: [{ type: "text", text: formatResume(job) }], details: job };
371
374
  },
372
375
  });
373
- return { braidTool, statusTool, cancelTool, updateTool, resumeTool };
374
- }
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}`);
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
+ }
393
389
  };
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");
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 };
405
406
  },
406
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) {
407
426
  const pending = new Map();
408
427
  const remind = (message) => {
409
428
  try {
@@ -415,6 +434,23 @@ export default function braidExtension(pi) {
415
434
  // Keep failed deliveries pending for the next settled retry.
416
435
  }
417
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
+ });
418
454
  const jobs = new BraidJobs((job) => {
419
455
  const { jobId, handle, status } = job;
420
456
  const reminderId = JSON.stringify([jobId, "job"]);
@@ -438,13 +474,23 @@ export default function braidExtension(pi) {
438
474
  };
439
475
  pending.set(reminderId, message);
440
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);
441
487
  });
442
488
  // Foreground cancellation can discard queued steering. Retry only reminders
443
489
  // that never entered context, once Pi has settled and emptied its queues.
444
490
  pi.on("message_start", (event) => {
445
491
  const message = event.message;
446
492
  if (message.role === "custom" &&
447
- (message.customType === "braid-completed" || message.customType === "braid-node-completed")) {
493
+ (message.customType === "braid-completed" || message.customType === "braid-node-completed" || message.customType === "braid-node-timeout")) {
448
494
  const details = message.details;
449
495
  if (details?.reminderId)
450
496
  pending.delete(details.reminderId);
@@ -456,7 +502,7 @@ export default function braidExtension(pi) {
456
502
  remind(message);
457
503
  }
458
504
  });
459
- const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs, blocking);
505
+ const { braidTool, statusTool, cancelTool, updateTool, resumeTool, pauseTool } = createBraidTools(jobs);
460
506
  registerDrain(pi, jobs, () => pending.size > 0);
461
507
  registerBraidCommand(pi, jobs);
462
508
  registerReviewCommand(pi, jobs);
@@ -466,16 +512,16 @@ export default function braidExtension(pi) {
466
512
  pi.registerTool(cancelTool);
467
513
  pi.registerTool(updateTool);
468
514
  pi.registerTool(resumeTool);
515
+ pi.registerTool(pauseTool);
469
516
  pi.on("session_shutdown", () => {
470
517
  pending.clear();
518
+ notices.clear();
471
519
  jobs.dispose();
472
520
  disposeWidget();
473
521
  });
474
522
  pi.on("before_agent_start", (event, ctx) => {
475
- if (pi.getFlag("braid-blocking") === true)
476
- warnBlocking(ctx);
477
523
  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.`,
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.`,
479
525
  };
480
526
  });
481
527
  }