@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/README.md CHANGED
@@ -2,37 +2,38 @@
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 by default, with
5
+ This optional Pi extension runs Braid agent graphs as background jobs, with
6
6
  bounded loops, live updates, pause/resume, and a live flow panel.
7
7
 
8
- **0.4 API:** The npm badge shows the published version; see
8
+ **0.5 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.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),
10
+ Review the [0.4 → 0.5 migration guide](https://github.com/Epsirom/braid/blob/v0.5.0/docs/compatibility.md#migrating-from-04-to-05)
11
+ before upgrading: running executions can pause and resume, configured node
12
+ timeouts pause by default, and blocking mode is removed. Users coming from
13
+ 0.3 also need the [0.3 → 0.4 guide](https://github.com/Epsirom/braid/blob/v0.5.0/docs/compatibility.md#migrating-from-03-to-04)
14
+ for print/JSON job draining and Pi SDK behavior. Users coming from 0.2 also need the
15
+ [0.2 → 0.3 guide](https://github.com/Epsirom/braid/blob/v0.5.0/docs/compatibility.md#migrating-from-02-to-03),
15
16
  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).
17
+ [0.1 → 0.2 guide](https://github.com/Epsirom/braid/blob/v0.5.0/docs/compatibility.md#migrating-from-01-to-02).
17
18
  For the previous release, use the
18
- [0.3.6 guide](https://github.com/Epsirom/braid/blob/v0.3.6/integrations/pi/README.md).
19
+ [0.4.0 guide](https://github.com/Epsirom/braid/blob/v0.4.0/integrations/pi/README.md).
19
20
 
20
21
  It registers:
21
22
 
22
23
  - `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.
24
+ immediately (print/JSON runs wait for it before exiting).
25
25
  - `braid_status` — retrieve progress and results with `{ "jobId": "..." }`, or
26
26
  add `"executionId": "..."` for an exact invocation's full output/error.
27
27
  `"nodeId": "..."` selects that definition's latest invocation. Omit all IDs
28
28
  to list jobs in the current session.
29
29
  - `braid_update` / `braid_resume` — edit live definitions or release paused executions.
30
- - `braid_cancel` — cancel a job with `{ "jobId": "..." }`.
30
+ - `braid_pause` — pause running executions, or the whole job, without losing their
31
+ work; with `after: true`, hold a running execution's result once it finishes.
32
+ - `braid_cancel` — cancel a job with `{ "jobId": "..." }`, or add
33
+ `"executionIds"` to fail only those executions.
31
34
  - `braid_finish` — print/JSON runs only: record the run outcome with
32
35
  `{ "status": "success" | "failure", "summary": "..." }`.
33
36
  - `/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.
36
37
  - `/braid:review [target or focus]` — run a bounded parallel review/fix loop.
37
38
  - `/braid:clear` — clear the progress widget above the editor; run it again while
38
39
  the widget is empty to restore what was cleared.
@@ -107,41 +108,13 @@ after outstanding Braid jobs drain. Hosts that stay running and want reminders
107
108
  to start new runs after settling should bind with another mode, for example
108
109
  `session.bindExtensions({ mode: "rpc" })`.
109
110
 
110
- ### Blocking mode (deprecated)
111
+ ### Migrating from blocking mode
111
112
 
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.
113
+ Blocking mode, `--braid-blocking`, and `/braid:blocking` are removed in 0.5.0.
114
+ Drop the flag or command. Print and JSON runs wait for background jobs before
115
+ exiting, and `braid_finish` sets the run outcome. The parent submits the graph,
116
+ ends its turn, and retrieves results with `braid_status` after the completion
117
+ reminder. `pauseAfter`, reminders, and live updates remain available.
145
118
 
146
119
  Jobs live in memory for the current Pi session. Quitting, reloading extensions,
147
120
  or switching/forking sessions aborts outstanding work and suppresses its
@@ -261,7 +234,18 @@ the current graph revision and paused execution IDs, then:
261
234
  ```
262
235
 
263
236
  Pass this to `braid_update`. It validates and commits the full change and resume
264
- atomically. To continue without edits, call `braid_resume({jobId,
237
+ atomically.
238
+
239
+ `pauseAfter` is captured when an execution starts, so changing it with
240
+ `braid_update` affects only later executions. To review the result of an
241
+ execution that is already running, call `braid_pause({jobId, executionIds,
242
+ after: true})`. The execution is not suspended: it keeps running, and once it
243
+ finishes, completed or failed, it is held exactly like `pauseAfter`, with a pause
244
+ reminder. `braid_resume({jobId, executionIds, after: true})` removes the hold
245
+ while it is still running, including one that came from `pauseAfter`. Either
246
+ way only those executions change, not their node definitions. A result that is
247
+ already held is released with the usual `braid_resume` or `braid_update`
248
+ `resume`. To continue without edits, call `braid_resume({jobId,
265
249
  expectedRevision, executionIds})`. Definitions can be changed while executions
266
250
  are running or waiting, including inside a loop. Existing instances keep their
267
251
  captured prompt, inputs, and policy; completion routes through the latest graph.
@@ -298,6 +282,50 @@ See [execution control](../../docs/execution-control.md) for loop schemas, exact
298
282
  update semantics, historical dependencies, and workspace lineage. There is no
299
283
  scheduler persistence across Pi reloads. Reminders alone do not pause execution.
300
284
 
285
+ ### Pausing running executions
286
+
287
+ `pauseAfter` holds a result after the execution finishes. To stop work that is
288
+ still running, call `braid_pause({jobId, executionIds})`. Each execution finishes
289
+ its running tool, then stops before its next model request. It keeps its
290
+ conversation, workspace, and tool budgets, and releases its concurrency slot to
291
+ other nodes. Messages sent to it from the `/braid` assistant while it is paused
292
+ are delivered when it resumes. Resume with `braid_resume({jobId, expectedRevision,
293
+ executionIds})` or `braid_update` with `resume`; the worker continues the same
294
+ conversation. A transient-error retry also waits at the pause point before resending.
295
+
296
+ Omit `executionIds` to pause the whole job: no new executions start and running
297
+ ones pause. `braid_resume({jobId})` resumes the job and the executions that the
298
+ job pause paused; executions paused separately stay paused.
299
+
300
+ The node timeout stops while an execution is suspended; the graph timeout keeps
301
+ running. `braid_status` shows suspended executions, a job pause, and who paused
302
+ them.
303
+
304
+ ### Node timeouts
305
+
306
+ When an execution exceeds `options.nodeTimeoutMs`, it is paused instead of
307
+ failed. Its current model request or shell command is stopped; the interrupted
308
+ call, with any output it produced, and any calls after it in the same batch are
309
+ recorded in its conversation. Its conversation, workspace, and tool budgets are
310
+ kept, and its successors wait. The parent is woken with a reminder that says how
311
+ long the execution ran, what it was doing, and what it can do next:
312
+
313
+ - `braid_resume({jobId, expectedRevision, executionIds})` gives it the job's
314
+ `nodeTimeoutMs` again; add `nodeTimeoutMs` for a different budget.
315
+ - `braid_update` with `resume` and `resumeOptions: { nodeTimeoutMs }` resumes it
316
+ together with graph changes.
317
+ - `braid_cancel({jobId, executionIds})` fails only that execution with
318
+ `NODE_TIMEOUT`; its successors follow the usual failure rules.
319
+
320
+ Resuming the whole job does not resume a timeout pause. Set
321
+ `options.onNodeTimeout: "fail"` to fail on timeout instead.
322
+
323
+ Users can pause and hold from the panel too (`p` and `h`, below). The parent is not woken for
324
+ that: one notice with the latest state per execution or job is added to its next
325
+ turn, and it is told not to resume work the user paused unless the user asks.
326
+ In print and JSON runs, a job held only by pauses counts as stalled: the parent
327
+ gets one warning, and the job is cancelled if it ends the next turn without resuming.
328
+
301
329
  ## Live flow panel
302
330
 
303
331
  In interactive Pi, background progress appears automatically **above the input
@@ -323,13 +351,24 @@ The bordered panel keeps the job header and keyboard controls visible while
323
351
  you scroll the flow and event log. It refreshes as nodes start, finish, fail,
324
352
  and pass outputs downstream.
325
353
  Use Left/Right to select jobs, Up/Down or Page Up/Page Down to scroll, `c` to
326
- cancel the selected job, and Escape or `q` to close the panel. Closing the panel
354
+ cancel the selected job, `p` to pause or resume it, and Escape or `q` to close the panel.
355
+ In node selection or an open session, `p` pauses or resumes that node's latest
356
+ execution instead; a suspended node shows `‖ PAUSED`, and one paused by its node
357
+ timeout also shows `timed out`. `h` holds that execution's result once it
358
+ finishes, removes the hold if one is set (its status shows `· hold`), or
359
+ releases a result that is already held. Press `x` twice there to cancel only
360
+ that execution; the parent gets a notice with its next turn. Closing the panel
327
361
  leaves jobs running. In RPC or noninteractive modes, use `braid_status`.
328
362
 
329
363
  A flowchart wider than the terminal is cropped to the terminal width around the
330
364
  first running node, or from its left edge; a `[chart 1–76 of 245 cols]` line
331
365
  shows the visible columns.
332
366
 
367
+ For loops, a forward edge opposite a feedback edge is drawn dotted, while the
368
+ retry feedback edge stays solid. For example, `/braid:review` shows the direct
369
+ `scope → check` handoff dotted and `check → scope` (`retry`) solid. All edges and
370
+ choice labels remain visible; runtime dependencies and handoffs are unchanged.
371
+
333
372
  Press `v` to select a node: the arrow keys move a `◆` marker through the
334
373
  flowchart (starting at a running node), and the view pans horizontally and
335
374
  scrolls vertically to keep the selected node in sight. If the flowchart cannot be
@@ -617,7 +656,9 @@ pass any of these fields in the `braid` tool's `options`:
617
656
  | --- | --- |
618
657
  | `maxToolRounds` | Maximum assistant responses containing tool calls, per node |
619
658
  | `maxToolCalls` | Maximum total requested tool calls, per node |
620
- | `nodeTimeoutMs` | Time allowed for each node after it starts, in milliseconds |
659
+ | `maxModelRetries` | Retries per model request for transient provider errors; default 3, `0` disables, at most 10 |
660
+ | `nodeTimeoutMs` | Active time allowed for each node after it starts, excluding pauses, in milliseconds |
661
+ | `onNodeTimeout` | `pause` (default) suspends a timed-out node and wakes the parent; `fail` fails it with `NODE_TIMEOUT` |
621
662
  | `graphTimeoutMs` | Time allowed for the entire graph, including queueing and pauses, in milliseconds |
622
663
  | `maxExecutions` | Total materialized execution records, including skips; default 1000, positive safe integer |
623
664
 
@@ -695,9 +736,8 @@ fails that node cleanly and does not invoke another provider.
695
736
 
696
737
  The Pi extension has no node or graph time limit by default. Use `braid_cancel`
697
738
  or press `c` in the flow panel to abort running nodes and mark queued nodes
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,
739
+ `cancelled`. Escape stops the foreground response or closes the panel without
740
+ cancelling jobs. To set a node or graph timeout,
701
741
  supply milliseconds in the submission tool input under `options`; each omitted
702
742
  timeout remains unlimited:
703
743
 
@@ -709,8 +749,8 @@ timeout remains unlimited:
709
749
  }
710
750
  ```
711
751
 
712
- The adapter sets `maxRetries: 0`, `cacheRetention: "none"`, and gives each node a
713
- fresh provider context. Tool budgets are unlimited unless `maxToolRounds` or
752
+ The adapter sets `cacheRetention: "none"` and gives each node a fresh provider
753
+ context. Tool budgets are unlimited unless `maxToolRounds` or
714
754
  `maxToolCalls` is set. Missing paths, invalid arguments, disallowed writes, and
715
755
  unavailable tools are returned as tool errors so the node can recover. Provider
716
756
  authentication and calls may still incur normal provider costs. Braid cannot
@@ -719,6 +759,29 @@ cancellation. Cancellation prevents further tool calls but does not roll back
719
759
  writes already made. Core waits for tracked writes, preserves checkpoints, and
720
760
  removes worker worktrees before completing cancellation.
721
761
 
762
+ ### Transient model errors
763
+
764
+ A transient provider error, such as a rate limit (HTTP 429), overload, a 5xx
765
+ response, or a dropped connection, is retried inside the running node execution.
766
+ The node keeps its conversation, tool results, workspace, decision, merge state,
767
+ and tool-budget counts; the failed request is sent again with the same context.
768
+ Tools run only after a complete model response, so a retry never repeats a
769
+ completed tool call. `options.maxModelRetries` sets the retries for each model
770
+ request (default 3; `0` disables them):
771
+
772
+ - If the provider rejects a request before its response starts, Pi's provider
773
+ client retries it with exponential backoff and honors `Retry-After`. A requested
774
+ wait longer than 60 seconds fails immediately.
775
+ - If a response stream fails after it started, Braid discards the partial
776
+ response and restarts the request after 2, 4, then 8 seconds.
777
+
778
+ Quota, billing, and other non-transient errors are not retried. Retries stop on
779
+ cancellation, and Braid does not start a wait that would outlast the node or
780
+ graph deadline. When retries run out, the node fails with the provider error and
781
+ follows the usual failure rules, including `requireSuccess`. Failed attempts stay
782
+ visible in the session view, and their reported usage is counted. Braid does not
783
+ retry a whole node execution.
784
+
722
785
  The tool output is capped at 50KB/2000 lines to protect Pi context. When exceeded,
723
786
  the adapter writes the complete text to a temporary file (mode 600 on POSIX;
724
787
  inherited ACLs on Windows) and ends the preview with its path, for running jobs
package/dist/command.js CHANGED
@@ -38,6 +38,10 @@ export class BraidPanel {
38
38
  chatOffset = 0;
39
39
  chatMaxOffset = 0;
40
40
  chatScope;
41
+ /** The outcome of the last pause, resume, or cancel key, until the next key. */
42
+ notice;
43
+ /** An execution x was pressed for once; a second x cancels it. */
44
+ cancelling;
41
45
  input;
42
46
  session;
43
47
  chat;
@@ -163,20 +167,20 @@ export class BraidPanel {
163
167
  ? ["Enter send Esc stop or hide Tab hide PgUp/PgDn or wheel scroll ctrl+c close",
164
168
  "Enter send · Esc hide · Tab hide", "Enter send · Tab hide"]
165
169
  : this.focus
166
- ? [`${tab}Esc back ←/→ executions ↑/↓ scroll End follow ctrl+o tools c cancel q close`,
167
- "Esc back · ←/→ executions · ↑/↓ scroll · q close", "Esc back · q close"]
170
+ ? [`${tab}Esc back ←/→ executions ↑/↓ scroll End follow ctrl+o tools p pause h hold x cancel execution c cancel job q close`,
171
+ "Esc back · ←/→ executions · p pause · q close", "Esc back · q close"]
168
172
  : this.choosing !== undefined
169
- ? [`${tab}↑/↓/←/→ choose node Enter open session Esc back PgUp/PgDn page q close`,
170
- "↑/↓ choose · Enter open · Esc back", "Enter open · Esc back"]
171
- : [`${tab}v select node ←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close`,
172
- "v select node · ←/→ jobs · ↑/↓ scroll · Esc close", "v select · Esc close"];
173
+ ? [`${tab}↑/↓/←/→ choose node Enter open session p pause h hold x cancel execution Esc back PgUp/PgDn page q close`,
174
+ "↑/↓ choose · Enter open · p pause · Esc back", "Enter open · Esc back"]
175
+ : [`${tab}v select node ←/→ jobs ↑/↓ scroll p pause job c cancel Esc close`,
176
+ "v select node · ←/→ jobs · p pause job · Esc close", "v select · Esc close"];
173
177
  const help = contentWidth >= 80 ? wide : contentWidth >= 42 ? medium : narrow;
174
178
  const status = graph ? graph.range : "Assistant";
175
179
  return [
176
180
  ...header,
177
181
  ...body.map(line => line === SEPARATOR ? rule("├", "┤") : row(line)),
178
182
  rule("├", "┤"),
179
- row(this.theme.fg("dim", `${status}${contentWidth >= 60 ? " · Jobs keep running when this panel closes" : ""}`)),
183
+ row(this.theme.fg("dim", this.notice ? plain(this.notice) : `${status}${contentWidth >= 60 ? " · Jobs keep running when this panel closes" : ""}`)),
180
184
  row(this.theme.fg("muted", help)),
181
185
  rule("╰", "╯"),
182
186
  ];
@@ -396,6 +400,24 @@ export class BraidPanel {
396
400
  const jobs = this.jobs.list();
397
401
  const index = Math.max(0, jobs.findIndex((job) => job.jobId === this.selected));
398
402
  const job = this.selected ? this.jobs.get(this.selected) : undefined;
403
+ this.notice = undefined;
404
+ const confirming = this.cancelling;
405
+ this.cancelling = undefined;
406
+ if (data === "p" && job) {
407
+ this.togglePause(job);
408
+ this.refresh();
409
+ return;
410
+ }
411
+ if (data === "h" && job && (this.focus || this.choosing !== undefined)) {
412
+ this.toggleHold(job);
413
+ this.refresh();
414
+ return;
415
+ }
416
+ if (data === "x" && job && (this.focus || this.choosing !== undefined)) {
417
+ this.cancelExecution(job, confirming);
418
+ this.refresh();
419
+ return;
420
+ }
399
421
  if (this.focus)
400
422
  this.sessionInput(data, job);
401
423
  else if (this.choosing !== undefined && job)
@@ -419,6 +441,103 @@ export class BraidPanel {
419
441
  this.jobs.cancel(this.selected);
420
442
  this.refresh();
421
443
  }
444
+ /**
445
+ * p pauses or resumes what is in view: the open execution, the selected
446
+ * node's latest execution, or the whole job. The parent agent gets a notice.
447
+ */
448
+ togglePause(job) {
449
+ const execution = job.execution;
450
+ try {
451
+ if (!execution || job.status !== "running")
452
+ throw new Error("Only a running job can be paused");
453
+ const executionId = this.executionInView(job);
454
+ if (!this.focus && this.choosing === undefined) {
455
+ if (execution.pause) {
456
+ this.jobs.resumeJob(job.jobId, "user");
457
+ this.notice = "Job resumed";
458
+ }
459
+ else {
460
+ this.jobs.pause(job.jobId, undefined, "user");
461
+ this.notice = "Job paused · no new executions start; running ones stop before their next model request";
462
+ }
463
+ return;
464
+ }
465
+ if (!executionId)
466
+ throw new Error("This node has not run yet");
467
+ const record = execution.executions[executionId];
468
+ if (record?.suspension && record.suspension.state !== "resuming") {
469
+ this.jobs.resume(job.jobId, [executionId], execution.revision, "user");
470
+ this.notice = "Execution resumed";
471
+ }
472
+ else {
473
+ this.jobs.pause(job.jobId, [executionId], "user");
474
+ this.notice = "Pausing · the execution stops before its next model request";
475
+ }
476
+ }
477
+ catch (error) {
478
+ this.notice = `Cannot pause or resume: ${error instanceof Error ? error.message : String(error)}`;
479
+ }
480
+ }
481
+ /**
482
+ * h toggles the hold on the execution in view: a running one keeps running and
483
+ * is held when it finishes; a held result is released. The parent agent gets a notice.
484
+ */
485
+ toggleHold(job) {
486
+ const execution = job.execution;
487
+ try {
488
+ if (!execution || job.status !== "running")
489
+ throw new Error("Only a running job can hold results");
490
+ const executionId = this.focus
491
+ ? "executionId" in this.focus ? this.focus.executionId : this.executionsOf(job, this.focus.nodeId).at(-1)
492
+ : this.executionsOf(job, this.choosing).at(-1);
493
+ if (!executionId)
494
+ throw new Error("This node has not run yet");
495
+ if (execution.pausedExecutionIds.includes(executionId)) {
496
+ this.jobs.resume(job.jobId, [executionId], execution.revision, "user");
497
+ this.notice = "Released · the result goes downstream";
498
+ }
499
+ else if (execution.executions[executionId]?.holdAfter) {
500
+ this.jobs.holdAfter(job.jobId, [executionId], false, "user");
501
+ this.notice = "Hold removed · the result goes downstream when the execution finishes";
502
+ }
503
+ else {
504
+ this.jobs.holdAfter(job.jobId, [executionId], true, "user");
505
+ this.notice = "Holding · the execution keeps running; its result waits for release when it finishes";
506
+ }
507
+ }
508
+ catch (error) {
509
+ this.notice = `Cannot hold or release: ${error instanceof Error ? error.message : String(error)}`;
510
+ }
511
+ }
512
+ /** The open execution, or the selected node's latest one. */
513
+ executionInView(job) {
514
+ return this.focus
515
+ ? "executionId" in this.focus ? this.focus.executionId : this.executionsOf(job, this.focus.nodeId).at(-1)
516
+ : this.choosing !== undefined ? this.executionsOf(job, this.choosing).at(-1) : undefined;
517
+ }
518
+ /**
519
+ * x cancels the execution in view after a second x: only it fails, and its
520
+ * successors follow the usual failure rules. The parent agent gets a notice.
521
+ */
522
+ cancelExecution(job, confirming) {
523
+ try {
524
+ if (!job.execution || job.status !== "running")
525
+ throw new Error("The job is not running");
526
+ const executionId = this.executionInView(job);
527
+ if (!executionId)
528
+ throw new Error("This node has not run yet");
529
+ if (confirming !== executionId) {
530
+ this.cancelling = executionId;
531
+ this.notice = "Press x again to cancel this execution · it fails and its successors follow the failure rules";
532
+ return;
533
+ }
534
+ this.jobs.cancelExecutions(job.jobId, [executionId], "user");
535
+ this.notice = "Execution cancelled";
536
+ }
537
+ catch (error) {
538
+ this.notice = `Cannot cancel: ${error instanceof Error ? error.message : String(error)}`;
539
+ }
540
+ }
422
541
  chooseInput(data, job) {
423
542
  const ids = this.nodeIds(job);
424
543
  const at = Math.max(0, ids.indexOf(this.choosing));
package/dist/display.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { render as renderMermaid } from "grok-mermaid";
2
2
  import { Text, sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
3
- import { formatActivityStatus, } from "@chrok/braid";
3
+ import { formatActivityStatus, formatPauseSource, } from "@chrok/braid";
4
4
  export const MAX_VISIBLE_EVENTS = 80;
5
5
  export const MAX_VISIBLE_NODES = 80;
6
6
  class FixedLines {
@@ -123,6 +123,8 @@ function graphParts(result) {
123
123
  from: event.from,
124
124
  to: event.to,
125
125
  ...(event.choice ? { choice: event.choice } : {}),
126
+ ...(event.feedback ? { feedback: event.feedback } : {}),
127
+ ...(event.executionId ? { executionId: event.executionId } : {}),
126
128
  });
127
129
  }
128
130
  }
@@ -164,22 +166,27 @@ function mermaidLabelLines(id, result, selected = false) {
164
166
  const node = result.nodes[id];
165
167
  if (!node)
166
168
  return [compact(id, 24), "pending"];
167
- const icon = node.status === "running"
168
- ? "▶ ACTIVE"
169
- : {
170
- completed: "✓",
171
- failed: "✗",
172
- skipped: "·",
173
- runnable: "◇",
174
- pending: "○",
175
- }[node.status];
176
- const status = node.status === "completed" ? "done" : node.status;
169
+ const suspended = node.suspension && node.suspension.state !== "pausing";
170
+ const icon = suspended
171
+ ? "‖ PAUSED"
172
+ : node.status === "running"
173
+ ? "▶ ACTIVE"
174
+ : {
175
+ completed: "✓",
176
+ failed: "✗",
177
+ skipped: "·",
178
+ runnable: "◇",
179
+ pending: "○",
180
+ }[node.status];
181
+ const status = node.suspension
182
+ ? `${node.suspension.state}${node.suspension.source === "node_timeout" ? " · timed out" : ""}`
183
+ : node.status === "completed" ? "done" : node.status;
177
184
  const decision = node.decision === undefined ? "" : ` → ${compact(node.decision, 16)}`;
178
185
  const now = "observedAt" in result ? result.observedAt : Date.now();
179
186
  const iteration = "executions" in result ? result.executions[node.executionId ?? ""]?.iteration : result.iterations?.[id];
180
187
  const lines = [
181
188
  `${selected ? SELECTED_NODE_MARK + icon.slice(1) : icon} ${compact(id, 24)}${iteration ? ` #${iteration}` : ""}`,
182
- `${status}${decision} · ${nodeElapsed(node, now)}`,
189
+ `${status}${node.holdAfter && (node.status === "running" || node.status === "runnable") ? " · hold" : ""}${decision} · ${nodeElapsed(node, now)}`,
183
190
  ];
184
191
  if (progress) {
185
192
  const window = progress.contextWindow === undefined
@@ -192,6 +199,7 @@ function mermaidLabelLines(id, result, selected = false) {
192
199
  }
193
200
  function mermaidSource(result, selected) {
194
201
  const parts = graphParts(result);
202
+ const feedbackSources = new Map(parts.edges.filter(edge => edge.feedback !== undefined).map(edge => [edge.to, edge.from]));
195
203
  const ids = Object.keys(parts.nodes);
196
204
  const names = new Map(ids.map((id, index) => [id, `n${index}`]));
197
205
  const escapeMermaidText = (value) => stripTerminalSequences(value)
@@ -214,7 +222,9 @@ function mermaidSource(result, selected) {
214
222
  lines.push(` ${names.get(id)}${shape}"${mermaidLabelLines(id, result, id === selected).map(escapeMermaidText).join("<br/>")}"${close}`);
215
223
  }
216
224
  for (const edge of parts.edges) {
217
- lines.push(` ${names.get(edge.from)} -->${edge.choice ? `|${escapeMermaidText(edge.choice)}|` : ""} ${names.get(edge.to)}`);
225
+ // Distinguish the direct data handoff from its reverse retry edge.
226
+ const arrow = edge.feedback === undefined && feedbackSources.get(edge.from) === edge.to ? "-.->" : "-->";
227
+ lines.push(` ${names.get(edge.from)} ${arrow}${edge.choice ? `|${escapeMermaidText(edge.choice)}|` : ""} ${names.get(edge.to)}`);
218
228
  }
219
229
  return lines.join("\n");
220
230
  }
@@ -285,6 +295,12 @@ function eventText(event) {
285
295
  case "graph_updated": return `graph updated · revision ${event.revision}`;
286
296
  case "execution_paused": return `paused · ${compact(event.nodeId, 40)} · ${event.executionId}`;
287
297
  case "execution_resumed": return `resumed · ${compact(event.nodeId, 40)} · ${event.executionId}`;
298
+ case "execution_suspending": return `pausing · ${compact(event.nodeId, 40)} · by ${compact(formatPauseSource(event.source), 20)}`;
299
+ case "execution_suspended": return `suspended · ${compact(event.nodeId, 40)} · by ${compact(formatPauseSource(event.source), 20)}`;
300
+ case "execution_hold_added": return `hold added · ${compact(event.nodeId, 40)} · by ${compact(event.source, 20)}`;
301
+ case "execution_hold_removed": return `hold removed · ${compact(event.nodeId, 40)} · by ${compact(event.source, 20)}`;
302
+ case "graph_paused": return `job paused · by ${compact(event.source, 20)}`;
303
+ case "graph_resumed": return "job resumed";
288
304
  case "loop_started": return `loop ${compact(event.loopId, 40)} · iteration ${event.iteration}`;
289
305
  case "loop_completed": return `loop ${compact(event.loopId, 40)} completed · iteration ${event.iteration}`;
290
306
  case "graph_created":
@@ -347,7 +363,7 @@ export function createLiveState() {
347
363
  progress: Object.create(null),
348
364
  events: [],
349
365
  latencyMs: 0,
350
- observedAt: Date.now(), revision: 0, pausedExecutionIds: [], iterations: Object.create(null),
366
+ observedAt: Date.now(), revision: 0, pausedExecutionIds: [], suspendedExecutionIds: [], iterations: Object.create(null),
351
367
  };
352
368
  }
353
369
  export function applyEvent(state, event) {
@@ -374,12 +390,16 @@ export function applyEvent(state, event) {
374
390
  from: event.from,
375
391
  to: event.to,
376
392
  ...(event.choice ? { choice: event.choice } : {}),
393
+ ...(event.feedback ? { feedback: event.feedback } : {}),
394
+ ...(event.executionId ? { executionId: event.executionId } : {}),
377
395
  });
378
396
  if (state.events.length > MAX_VISIBLE_EVENTS)
379
397
  state.events.shift();
380
398
  if (event.type === "graph_completed" || event.type === "graph_failed") {
381
399
  state.status = event.type === "graph_completed" ? "completed" : "failed";
382
400
  state.pausedExecutionIds = [];
401
+ state.suspendedExecutionIds = [];
402
+ delete state.pause;
383
403
  if (event.type === "graph_failed")
384
404
  state.error = { ...event.error };
385
405
  return;
@@ -416,8 +436,16 @@ export function applyEvent(state, event) {
416
436
  }
417
437
  if (event.type === "execution_paused")
418
438
  state.pausedExecutionIds = [...(state.pausedExecutionIds ?? []), event.executionId];
439
+ if (event.type === "graph_paused")
440
+ state.pause = { source: event.source, pausedAt: event.timestamp };
441
+ if (event.type === "graph_resumed")
442
+ delete state.pause;
443
+ if (event.type === "execution_suspended")
444
+ state.suspendedExecutionIds = [...(state.suspendedExecutionIds ?? []), event.executionId];
419
445
  if (event.type === "execution_resumed")
420
446
  state.pausedExecutionIds = state.pausedExecutionIds?.filter(id => id !== event.executionId) ?? [];
447
+ if (event.type === "execution_resumed" || event.type === "node_completed" || event.type === "node_failed")
448
+ state.suspendedExecutionIds = state.suspendedExecutionIds?.filter(id => id !== event.executionId) ?? [];
421
449
  const node = "nodeId" in event && Object.hasOwn(state.nodes, event.nodeId)
422
450
  ? state.nodes[event.nodeId]
423
451
  : undefined;
@@ -445,8 +473,24 @@ export function applyEvent(state, event) {
445
473
  node.status = "running";
446
474
  node.startedAt = event.timestamp;
447
475
  break;
476
+ case "execution_suspending":
477
+ node.suspension = { state: "pausing", source: event.source, requestedAt: event.timestamp };
478
+ break;
479
+ case "execution_suspended":
480
+ node.suspension = { state: "suspended", source: event.source, requestedAt: node.suspension?.requestedAt ?? event.timestamp, suspendedAt: event.timestamp };
481
+ break;
482
+ case "execution_resumed":
483
+ delete node.suspension;
484
+ break;
485
+ case "execution_hold_added":
486
+ node.holdAfter = { source: event.source, requestedAt: event.timestamp };
487
+ break;
488
+ case "execution_hold_removed":
489
+ delete node.holdAfter;
490
+ break;
448
491
  case "node_completed":
449
492
  case "node_failed":
493
+ delete node.suspension;
450
494
  node.status = event.type === "node_completed" ? "completed" : "failed";
451
495
  node.finishedAt = event.timestamp;
452
496
  node.latencyMs = event.latencyMs;
@@ -625,8 +669,12 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
625
669
  }
626
670
  if (result.error)
627
671
  lines.push(theme.fg("error", `${result.error.code}: ${compact(result.error.message, 120)}`));
672
+ if ("pause" in result && result.pause)
673
+ lines.push(theme.fg("warning", `job paused by ${compact(result.pause.source, 20)} · no new executions start`));
628
674
  if ("pausedExecutionIds" in result && result.pausedExecutionIds?.length)
629
675
  lines.push(theme.fg("warning", `${result.pausedExecutionIds.length} paused executions · revision ${result.revision ?? 0}`));
676
+ if ("suspendedExecutionIds" in result && result.suspendedExecutionIds?.length)
677
+ lines.push(theme.fg("warning", `${result.suspendedExecutionIds.length} suspended executions · revision ${result.revision ?? 0}`));
630
678
  if ("executions" in result)
631
679
  lines.push(theme.fg("dim", `${Object.keys(result.executions).length} executions · revision ${result.revision}`));
632
680
  const chartStart = lines.length;
package/dist/drain.js CHANGED
@@ -3,7 +3,7 @@ import { defineTool, } from "@earendil-works/pi-coding-agent";
3
3
  /** Pi exits once print and JSON runs settle, so Braid drains jobs before that. */
4
4
  export const isSingleShot = (ctx) => ctx.mode === "print" || ctx.mode === "json";
5
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.";
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
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
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
9
  /** The run's wall-clock budget is checked by a timer, so the drain also wakes periodically. */
@@ -16,9 +16,15 @@ export function parseDuration(value) {
16
16
  throw new Error(`Invalid --braid-ci-timeout ${JSON.stringify(value)}: use a positive duration such as 90000, 90s, 30m, or 2h`);
17
17
  return ms;
18
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";
19
25
  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. ` +
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. ` +
22
28
  `If you end your turn again without doing so, Braid cancels these jobs and the run exits with a failure status. [/system-reminder]`;
23
29
  /**
24
30
  * Keeps print and JSON runs alive until every Braid job reaches a terminal
@@ -138,7 +144,7 @@ export function registerDrain(pi, jobs, hasPendingReminders) {
138
144
  if (!outstanding.length)
139
145
  return settle(event);
140
146
  if (outstanding.every(job => job.stalled)) {
141
- const key = JSON.stringify(outstanding.map(job => [job.jobId, job.revision, job.pausedExecutionIds]));
147
+ const key = JSON.stringify(outstanding.map(job => [job.jobId, job.revision, job.pausedExecutionIds, job.suspendedExecutionIds, job.paused]));
142
148
  if (key !== warnedStall) {
143
149
  warnedStall = key;
144
150
  return {
@@ -153,7 +159,7 @@ export function registerDrain(pi, jobs, hasPendingReminders) {
153
159
  };
154
160
  }
155
161
  // 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(", ")}`);
162
+ cancelOutstanding(`paused work was never resumed; cancelled ${outstanding.map(job => job.handle).join(", ")}`);
157
163
  }
158
164
  await nextChange();
159
165
  }