@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/README.md CHANGED
@@ -2,32 +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.3 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.2 → 0.3 migration guide](https://github.com/Epsirom/braid/blob/v0.3.0/docs/compatibility.md#migrating-from-02-to-03)
11
- before upgrading: writable nodes now have shell tools, checkpoints exclude
12
- untracked ignored outputs, and reminders can interrupt work between model steps.
13
- Users coming from 0.1 also need the
14
- [0.1 → 0.2 guide](https://github.com/Epsirom/braid/blob/v0.3.0/docs/compatibility.md#migrating-from-01-to-02).
10
+ Review the [0.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),
16
+ and users coming from 0.1 the
17
+ [0.1 → 0.2 guide](https://github.com/Epsirom/braid/blob/v0.5.0/docs/compatibility.md#migrating-from-01-to-02).
15
18
  For the previous release, use the
16
- [0.2.1 guide](https://github.com/Epsirom/braid/blob/v0.2.1/integrations/pi/README.md).
19
+ [0.4.0 guide](https://github.com/Epsirom/braid/blob/v0.4.0/integrations/pi/README.md).
17
20
 
18
21
  It registers:
19
22
 
20
23
  - `braid` — submit a graph with optional bounded loops; receive a `jobId`
21
- immediately in background mode, or final results in experimental blocking mode.
24
+ immediately (print/JSON runs wait for it before exiting).
22
25
  - `braid_status` — retrieve progress and results with `{ "jobId": "..." }`, or
23
26
  add `"executionId": "..."` for an exact invocation's full output/error.
24
27
  `"nodeId": "..."` selects that definition's latest invocation. Omit all IDs
25
28
  to list jobs in the current session.
26
29
  - `braid_update` / `braid_resume` — edit live definitions or release paused executions.
27
- - `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.
34
+ - `braid_finish` — print/JSON runs only: record the run outcome with
35
+ `{ "status": "success" | "failure", "summary": "..." }`.
28
36
  - `/braid [jobId]` — open a live flow panel in interactive Pi.
29
- - `/braid:blocking [on|off]` — experimental: enable or disable blocking for new `braid` tool calls;
30
- omit the argument to show the current mode.
31
37
  - `/braid:review [target or focus]` — run a bounded parallel review/fix loop.
32
38
  - `/braid:clear` — clear the progress widget above the editor; run it again while
33
39
  the widget is empty to restore what was cleared.
@@ -53,37 +59,62 @@ work, or end its turn if it needs the result, and not to sleep, use a timer, or
53
59
  poll in a loop to wait for the job.
54
60
  Stopping the foreground response does not stop background jobs.
55
61
 
56
- > **Experimental:** blocking mode is a stopgap for single-shot runs. Its flag,
57
- > command, and behavior may change or be replaced by an async-preserving CI mode
58
- > that keeps reminders, `pauseAfter`, and live updates
59
- > ([#80](https://github.com/Epsirom/braid/issues/80)).
62
+ ### Single-shot and CI runs
60
63
 
61
- For CI or other single-shot runs, enable blocking manually when starting Pi:
64
+ In print mode (`pi -p`) and `--mode json`, background execution works unchanged:
65
+ the run stays open until every Braid job reaches a terminal status and its
66
+ reminder has been delivered, then exits after the parent's final response.
62
67
 
63
68
  ```sh
64
- pi --braid-blocking -p "Use Braid to review the changes and summarize the results."
69
+ pi -p "Use Braid to review the changes and summarize the results."
65
70
  ```
66
71
 
67
- With `@chrok/pi-braid` installed, `--braid-blocking` makes every `braid` tool call
68
- wait for the entire graph and workspace cleanup, then return final status, outputs,
69
- and usage directly. The parent can summarize those results before `pi -p` exits.
70
- It also works with `--mode json` for structured tool results. The CLI flag sets the
71
- initial mode; within a session, use `/braid:blocking on` to enable it,
72
- `/braid:blocking off` to disable it, or `/braid:blocking` to check the current mode.
73
- Switching affects only new `braid` tool calls; running jobs keep their original
74
- mode. The session override is not persisted. These are user controls, not model
75
- tool parameters; the default remains background execution.
76
- For CI pass/fail decisions, inspect the returned job status: a failed graph does
77
- not by itself set Pi's process exit code.
78
- Blocking calls emit an initial progress update with the job handle while waiting.
79
- They suppress node/job completion reminders except for cancellation: stopping the
80
- foreground response cancels their work, waits for cleanup, and sends a reminder
81
- so the next turn can retrieve partial outputs and recovery checkpoints.
82
- Wait/result retrieval errors include the existing job handle and any terminal
83
- usage; retrieve that job with `braid_status` instead of submitting the graph again.
84
- `pauseAfter: true` is rejected before starting a blocking job because it requires
85
- a later resume call. The interactive
86
- `/braid:review` command continues to use its background review workflow.
72
+ The parent submits, ends its turn, and is resumed by the reminder as in
73
+ interactive Pi, so `pauseAfter`, `notifyOnCompletion`, `braid_update`, and
74
+ `braid_resume` keep working. Paused executions are never resumed automatically.
75
+ If the parent ends its turn while only paused executions remain, it receives one
76
+ warning naming them; ending the next turn without resuming them cancels their
77
+ jobs, gives the parent a final turn on the cancellation reminder, and exits 1.
78
+
79
+ `--braid-ci-timeout <duration>` bounds the whole run, starting at its first
80
+ prompt. It accepts milliseconds or an `s`, `m`, or `h` suffix (for example
81
+ `--braid-ci-timeout 30m`). On expiry, outstanding jobs are cancelled, the parent
82
+ gets a final turn on the cancellation reminders, and the run exits 1. Either
83
+ forced cancellation prints a `Braid:` line on stderr.
84
+
85
+ A failed graph does not by itself fail the run. Instead, the parent is told to
86
+ call `braid_finish` with `status` `success` or `failure` and a short `summary`
87
+ once the task is done. The tool only records the outcome; the run still ends
88
+ when the parent ends its turn with no Braid job outstanding, and a later call
89
+ replaces the earlier outcome. A final `failure` exits 1 and prints
90
+ `Braid: run reported failure: <summary>` on stderr. `success` never overrides a
91
+ non-zero exit from a forced cancellation or from Pi itself. If the parent never
92
+ calls it, the exit status is Pi's own apart from forced cancellations.
93
+
94
+ In `--mode json`, the final outcome is also written as a session entry, after the
95
+ `tool_execution_end` event of the call itself:
96
+
97
+ ```json
98
+ {"type":"entry_appended","entry":{"type":"custom","customType":"braid-finish","data":{"status":"failure","summary":"Review found 2 blocking issues.","forcedFailures":[]},...}}
99
+ ```
100
+
101
+ `forcedFailures` lists any forced cancellations, which fail the run whatever the
102
+ reported status. The entry is not part of model context, and text mode still
103
+ prints only the parent's final response. `braid_finish` is not offered in
104
+ interactive or RPC sessions.
105
+
106
+ Pi SDK sessions default to print mode too, so `session.prompt()` returns only
107
+ after outstanding Braid jobs drain. Hosts that stay running and want reminders
108
+ to start new runs after settling should bind with another mode, for example
109
+ `session.bindExtensions({ mode: "rpc" })`.
110
+
111
+ ### Migrating from blocking mode
112
+
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.
87
118
 
88
119
  Jobs live in memory for the current Pi session. Quitting, reloading extensions,
89
120
  or switching/forking sessions aborts outstanding work and suppresses its
@@ -203,7 +234,18 @@ the current graph revision and paused execution IDs, then:
203
234
  ```
204
235
 
205
236
  Pass this to `braid_update`. It validates and commits the full change and resume
206
- 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,
207
249
  expectedRevision, executionIds})`. Definitions can be changed while executions
208
250
  are running or waiting, including inside a loop. Existing instances keep their
209
251
  captured prompt, inputs, and policy; completion routes through the latest graph.
@@ -240,6 +282,50 @@ See [execution control](../../docs/execution-control.md) for loop schemas, exact
240
282
  update semantics, historical dependencies, and workspace lineage. There is no
241
283
  scheduler persistence across Pi reloads. Reminders alone do not pause execution.
242
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
+
243
329
  ## Live flow panel
244
330
 
245
331
  In interactive Pi, background progress appears automatically **above the input
@@ -265,13 +351,24 @@ The bordered panel keeps the job header and keyboard controls visible while
265
351
  you scroll the flow and event log. It refreshes as nodes start, finish, fail,
266
352
  and pass outputs downstream.
267
353
  Use Left/Right to select jobs, Up/Down or Page Up/Page Down to scroll, `c` to
268
- 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
269
361
  leaves jobs running. In RPC or noninteractive modes, use `braid_status`.
270
362
 
271
363
  A flowchart wider than the terminal is cropped to the terminal width around the
272
364
  first running node, or from its left edge; a `[chart 1–76 of 245 cols]` line
273
365
  shows the visible columns.
274
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
+
275
372
  Press `v` to select a node: the arrow keys move a `◆` marker through the
276
373
  flowchart (starting at a running node), and the view pans horizontally and
277
374
  scrolls vertically to keep the selected node in sight. If the flowchart cannot be
@@ -559,7 +656,9 @@ pass any of these fields in the `braid` tool's `options`:
559
656
  | --- | --- |
560
657
  | `maxToolRounds` | Maximum assistant responses containing tool calls, per node |
561
658
  | `maxToolCalls` | Maximum total requested tool calls, per node |
562
- | `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` |
563
662
  | `graphTimeoutMs` | Time allowed for the entire graph, including queueing and pauses, in milliseconds |
564
663
  | `maxExecutions` | Total materialized execution records, including skips; default 1000, positive safe integer |
565
664
 
@@ -637,9 +736,8 @@ fails that node cleanly and does not invoke another provider.
637
736
 
638
737
  The Pi extension has no node or graph time limit by default. Use `braid_cancel`
639
738
  or press `c` in the flow panel to abort running nodes and mark queued nodes
640
- `cancelled`. In the default background mode, Escape stops the foreground response
641
- or closes the panel without cancelling jobs. In blocking mode, stopping the
642
- foreground response also cancels its Braid job. To set a node or graph timeout,
739
+ `cancelled`. Escape stops the foreground response or closes the panel without
740
+ cancelling jobs. To set a node or graph timeout,
643
741
  supply milliseconds in the submission tool input under `options`; each omitted
644
742
  timeout remains unlimited:
645
743
 
@@ -651,8 +749,8 @@ timeout remains unlimited:
651
749
  }
652
750
  ```
653
751
 
654
- The adapter sets `maxRetries: 0`, `cacheRetention: "none"`, and gives each node a
655
- 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
656
754
  `maxToolCalls` is set. Missing paths, invalid arguments, disallowed writes, and
657
755
  unavailable tools are returned as tool errors so the node can recover. Provider
658
756
  authentication and calls may still incur normal provider costs. Braid cannot
@@ -661,6 +759,29 @@ cancellation. Cancellation prevents further tool calls but does not roll back
661
759
  writes already made. Core waits for tracked writes, preserves checkpoints, and
662
760
  removes worker worktrees before completing cancellation.
663
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
+
664
785
  The tool output is capped at 50KB/2000 lines to protect Pi context. When exceeded,
665
786
  the adapter writes the complete text to a temporary file (mode 600 on POSIX;
666
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;