@chrok/pi-braid 0.4.0 → 0.5.1

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.1/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.1/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.1/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.1/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.5.0 guide](https://github.com/Epsirom/braid/blob/v0.5.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.
@@ -274,6 +258,19 @@ Pause reminders describe the event when it occurred: check current status and
274
258
  paused IDs before attempting an update/resume, since the job can time out or be
275
259
  cancelled before the parent handles the reminder.
276
260
 
261
+ Execute and decision nodes have a `request_replan` tool. A node calls it to
262
+ report a finding that contradicts its prompt's assumptions, makes its task
263
+ unnecessary, or affects more than its task, with a reason and optionally a
264
+ suggested graph edit (`upsertNodes`, `removeNodeIds`, `addEdges`, `removeEdges`)
265
+ that core checks against the current graph. Nodes cannot change the graph
266
+ themselves, and they see only the goal, their prompt, and direct predecessors:
267
+ the parent decides which planned work a finding affects. Saying in a node's
268
+ prompt what related work other nodes handle makes its requests more precise. The parent gets a waking reminder at once;
269
+ the node keeps working, and its result is held when it finishes, like
270
+ `pauseAfter`. `braid_status` shows the reason and suggestion. The parent can stop
271
+ other branches with `braid_pause`, then change the graph and release the node
272
+ with `braid_update` and `resume`, keep the plan with `braid_resume`, or cancel.
273
+
277
274
  Focused `braid_status` reads also start with the current control fields: graph
278
275
  status, revision, and paused execution IDs. Use that current revision for
279
276
  updates even when inspecting an older invocation.
@@ -298,6 +295,50 @@ See [execution control](../../docs/execution-control.md) for loop schemas, exact
298
295
  update semantics, historical dependencies, and workspace lineage. There is no
299
296
  scheduler persistence across Pi reloads. Reminders alone do not pause execution.
300
297
 
298
+ ### Pausing running executions
299
+
300
+ `pauseAfter` holds a result after the execution finishes. To stop work that is
301
+ still running, call `braid_pause({jobId, executionIds})`. Each execution finishes
302
+ its running tool, then stops before its next model request. It keeps its
303
+ conversation, workspace, and tool budgets, and releases its concurrency slot to
304
+ other nodes. Messages sent to it from the `/braid` assistant while it is paused
305
+ are delivered when it resumes. Resume with `braid_resume({jobId, expectedRevision,
306
+ executionIds})` or `braid_update` with `resume`; the worker continues the same
307
+ conversation. A transient-error retry also waits at the pause point before resending.
308
+
309
+ Omit `executionIds` to pause the whole job: no new executions start and running
310
+ ones pause. `braid_resume({jobId})` resumes the job and the executions that the
311
+ job pause paused; executions paused separately stay paused.
312
+
313
+ The node timeout stops while an execution is suspended; the graph timeout keeps
314
+ running. `braid_status` shows suspended executions, a job pause, and who paused
315
+ them.
316
+
317
+ ### Node timeouts
318
+
319
+ When an execution exceeds `options.nodeTimeoutMs`, it is paused instead of
320
+ failed. Its current model request or shell command is stopped; the interrupted
321
+ call, with any output it produced, and any calls after it in the same batch are
322
+ recorded in its conversation. Its conversation, workspace, and tool budgets are
323
+ kept, and its successors wait. The parent is woken with a reminder that says how
324
+ long the execution ran, what it was doing, and what it can do next:
325
+
326
+ - `braid_resume({jobId, expectedRevision, executionIds})` gives it the job's
327
+ `nodeTimeoutMs` again; add `nodeTimeoutMs` for a different budget.
328
+ - `braid_update` with `resume` and `resumeOptions: { nodeTimeoutMs }` resumes it
329
+ together with graph changes.
330
+ - `braid_cancel({jobId, executionIds})` fails only that execution with
331
+ `NODE_TIMEOUT`; its successors follow the usual failure rules.
332
+
333
+ Resuming the whole job does not resume a timeout pause. Set
334
+ `options.onNodeTimeout: "fail"` to fail on timeout instead.
335
+
336
+ Users can pause and hold from the panel too (`p` and `h`, below). The parent is not woken for
337
+ that: one notice with the latest state per execution or job is added to its next
338
+ turn, and it is told not to resume work the user paused unless the user asks.
339
+ In print and JSON runs, a job held only by pauses counts as stalled: the parent
340
+ gets one warning, and the job is cancelled if it ends the next turn without resuming.
341
+
301
342
  ## Live flow panel
302
343
 
303
344
  In interactive Pi, background progress appears automatically **above the input
@@ -323,13 +364,24 @@ The bordered panel keeps the job header and keyboard controls visible while
323
364
  you scroll the flow and event log. It refreshes as nodes start, finish, fail,
324
365
  and pass outputs downstream.
325
366
  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
367
+ cancel the selected job, `p` to pause or resume it, and Escape or `q` to close the panel.
368
+ In node selection or an open session, `p` pauses or resumes that node's latest
369
+ execution instead; a suspended node shows `‖ PAUSED`, and one paused by its node
370
+ timeout also shows `timed out`. `h` holds that execution's result once it
371
+ finishes, removes the hold if one is set (its status shows `· hold`), or
372
+ releases a result that is already held. Press `x` twice there to cancel only
373
+ that execution; the parent gets a notice with its next turn. Closing the panel
327
374
  leaves jobs running. In RPC or noninteractive modes, use `braid_status`.
328
375
 
329
376
  A flowchart wider than the terminal is cropped to the terminal width around the
330
377
  first running node, or from its left edge; a `[chart 1–76 of 245 cols]` line
331
378
  shows the visible columns.
332
379
 
380
+ For loops, a forward edge opposite a feedback edge is drawn dotted, while the
381
+ retry feedback edge stays solid. For example, `/braid:review` shows the direct
382
+ `scope → check` handoff dotted and `check → scope` (`retry`) solid. All edges and
383
+ choice labels remain visible; runtime dependencies and handoffs are unchanged.
384
+
333
385
  Press `v` to select a node: the arrow keys move a `◆` marker through the
334
386
  flowchart (starting at a running node), and the view pans horizontally and
335
387
  scrolls vertically to keep the selected node in sight. If the flowchart cannot be
@@ -617,7 +669,9 @@ pass any of these fields in the `braid` tool's `options`:
617
669
  | --- | --- |
618
670
  | `maxToolRounds` | Maximum assistant responses containing tool calls, per node |
619
671
  | `maxToolCalls` | Maximum total requested tool calls, per node |
620
- | `nodeTimeoutMs` | Time allowed for each node after it starts, in milliseconds |
672
+ | `maxModelRetries` | Retries per model request for transient provider errors; default 3, `0` disables, at most 10 |
673
+ | `nodeTimeoutMs` | Active time allowed for each node after it starts, excluding pauses, in milliseconds |
674
+ | `onNodeTimeout` | `pause` (default) suspends a timed-out node and wakes the parent; `fail` fails it with `NODE_TIMEOUT` |
621
675
  | `graphTimeoutMs` | Time allowed for the entire graph, including queueing and pauses, in milliseconds |
622
676
  | `maxExecutions` | Total materialized execution records, including skips; default 1000, positive safe integer |
623
677
 
@@ -695,9 +749,8 @@ fails that node cleanly and does not invoke another provider.
695
749
 
696
750
  The Pi extension has no node or graph time limit by default. Use `braid_cancel`
697
751
  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,
752
+ `cancelled`. Escape stops the foreground response or closes the panel without
753
+ cancelling jobs. To set a node or graph timeout,
701
754
  supply milliseconds in the submission tool input under `options`; each omitted
702
755
  timeout remains unlimited:
703
756
 
@@ -709,8 +762,8 @@ timeout remains unlimited:
709
762
  }
710
763
  ```
711
764
 
712
- The adapter sets `maxRetries: 0`, `cacheRetention: "none"`, and gives each node a
713
- fresh provider context. Tool budgets are unlimited unless `maxToolRounds` or
765
+ The adapter sets `cacheRetention: "none"` and gives each node a fresh provider
766
+ context. Tool budgets are unlimited unless `maxToolRounds` or
714
767
  `maxToolCalls` is set. Missing paths, invalid arguments, disallowed writes, and
715
768
  unavailable tools are returned as tool errors so the node can recover. Provider
716
769
  authentication and calls may still incur normal provider costs. Braid cannot
@@ -719,6 +772,29 @@ cancellation. Cancellation prevents further tool calls but does not roll back
719
772
  writes already made. Core waits for tracked writes, preserves checkpoints, and
720
773
  removes worker worktrees before completing cancellation.
721
774
 
775
+ ### Transient model errors
776
+
777
+ A transient provider error, such as a rate limit (HTTP 429), overload, a 5xx
778
+ response, or a dropped connection, is retried inside the running node execution.
779
+ The node keeps its conversation, tool results, workspace, decision, merge state,
780
+ and tool-budget counts; the failed request is sent again with the same context.
781
+ Tools run only after a complete model response, so a retry never repeats a
782
+ completed tool call. `options.maxModelRetries` sets the retries for each model
783
+ request (default 3; `0` disables them):
784
+
785
+ - If the provider rejects a request before its response starts, Pi's provider
786
+ client retries it with exponential backoff and honors `Retry-After`. A requested
787
+ wait longer than 60 seconds fails immediately.
788
+ - If a response stream fails after it started, Braid discards the partial
789
+ response and restarts the request after 2, 4, then 8 seconds.
790
+
791
+ Quota, billing, and other non-transient errors are not retried. Retries stop on
792
+ cancellation, and Braid does not start a wait that would outlast the node or
793
+ graph deadline. When retries run out, the node fails with the provider error and
794
+ follows the usual failure rules, including `requireSuccess`. Failed attempts stay
795
+ visible in the session view, and their reported usage is counted. Braid does not
796
+ retry a whole node execution.
797
+
722
798
  The tool output is capped at 50KB/2000 lines to protect Pi context. When exceeded,
723
799
  the adapter writes the complete text to a temporary file (mode 600 on POSIX;
724
800
  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));
@@ -0,0 +1,17 @@
1
+ import * as core from "@chrok/braid";
2
+ /** The exact core release this extension depends on; they release together. */
3
+ export const REQUIRED_CORE_VERSION = "0.5.1";
4
+ /**
5
+ * A Pi process that loaded core before an upgrade can keep it while loading the new extension,
6
+ * which then fails on its first missing core helper as an opaque model error. Releases before
7
+ * 0.5.1 do not export a version at all.
8
+ */
9
+ export function assertCoreVersion(loaded = core) {
10
+ const version = loaded.BRAID_VERSION;
11
+ if (version === REQUIRED_CORE_VERSION)
12
+ return;
13
+ const found = typeof version === "string" ? `@chrok/braid ${version}` : "an older @chrok/braid";
14
+ throw new Error(`@chrok/pi-braid requires @chrok/braid ${REQUIRED_CORE_VERSION}, but ${found} is loaded. ` +
15
+ "Install matching versions (check with `npm ls @chrok/pi-braid @chrok/braid`), then restart Pi: " +
16
+ "a running Pi process keeps the core it loaded before an upgrade.");
17
+ }
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,13 @@ 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 "replan_requested": return `replan requested · ${compact(event.nodeId, 40)}: ${compact(event.reason, 64)}`;
303
+ case "graph_paused": return `job paused · by ${compact(event.source, 20)}`;
304
+ case "graph_resumed": return "job resumed";
288
305
  case "loop_started": return `loop ${compact(event.loopId, 40)} · iteration ${event.iteration}`;
289
306
  case "loop_completed": return `loop ${compact(event.loopId, 40)} completed · iteration ${event.iteration}`;
290
307
  case "graph_created":
@@ -347,7 +364,7 @@ export function createLiveState() {
347
364
  progress: Object.create(null),
348
365
  events: [],
349
366
  latencyMs: 0,
350
- observedAt: Date.now(), revision: 0, pausedExecutionIds: [], iterations: Object.create(null),
367
+ observedAt: Date.now(), revision: 0, pausedExecutionIds: [], suspendedExecutionIds: [], iterations: Object.create(null),
351
368
  };
352
369
  }
353
370
  export function applyEvent(state, event) {
@@ -374,12 +391,16 @@ export function applyEvent(state, event) {
374
391
  from: event.from,
375
392
  to: event.to,
376
393
  ...(event.choice ? { choice: event.choice } : {}),
394
+ ...(event.feedback ? { feedback: event.feedback } : {}),
395
+ ...(event.executionId ? { executionId: event.executionId } : {}),
377
396
  });
378
397
  if (state.events.length > MAX_VISIBLE_EVENTS)
379
398
  state.events.shift();
380
399
  if (event.type === "graph_completed" || event.type === "graph_failed") {
381
400
  state.status = event.type === "graph_completed" ? "completed" : "failed";
382
401
  state.pausedExecutionIds = [];
402
+ state.suspendedExecutionIds = [];
403
+ delete state.pause;
383
404
  if (event.type === "graph_failed")
384
405
  state.error = { ...event.error };
385
406
  return;
@@ -416,8 +437,16 @@ export function applyEvent(state, event) {
416
437
  }
417
438
  if (event.type === "execution_paused")
418
439
  state.pausedExecutionIds = [...(state.pausedExecutionIds ?? []), event.executionId];
440
+ if (event.type === "graph_paused")
441
+ state.pause = { source: event.source, pausedAt: event.timestamp };
442
+ if (event.type === "graph_resumed")
443
+ delete state.pause;
444
+ if (event.type === "execution_suspended")
445
+ state.suspendedExecutionIds = [...(state.suspendedExecutionIds ?? []), event.executionId];
419
446
  if (event.type === "execution_resumed")
420
447
  state.pausedExecutionIds = state.pausedExecutionIds?.filter(id => id !== event.executionId) ?? [];
448
+ if (event.type === "execution_resumed" || event.type === "node_completed" || event.type === "node_failed")
449
+ state.suspendedExecutionIds = state.suspendedExecutionIds?.filter(id => id !== event.executionId) ?? [];
421
450
  const node = "nodeId" in event && Object.hasOwn(state.nodes, event.nodeId)
422
451
  ? state.nodes[event.nodeId]
423
452
  : undefined;
@@ -445,8 +474,24 @@ export function applyEvent(state, event) {
445
474
  node.status = "running";
446
475
  node.startedAt = event.timestamp;
447
476
  break;
477
+ case "execution_suspending":
478
+ node.suspension = { state: "pausing", source: event.source, requestedAt: event.timestamp };
479
+ break;
480
+ case "execution_suspended":
481
+ node.suspension = { state: "suspended", source: event.source, requestedAt: node.suspension?.requestedAt ?? event.timestamp, suspendedAt: event.timestamp };
482
+ break;
483
+ case "execution_resumed":
484
+ delete node.suspension;
485
+ break;
486
+ case "execution_hold_added":
487
+ node.holdAfter = { source: event.source, requestedAt: event.timestamp };
488
+ break;
489
+ case "execution_hold_removed":
490
+ delete node.holdAfter;
491
+ break;
448
492
  case "node_completed":
449
493
  case "node_failed":
494
+ delete node.suspension;
450
495
  node.status = event.type === "node_completed" ? "completed" : "failed";
451
496
  node.finishedAt = event.timestamp;
452
497
  node.latencyMs = event.latencyMs;
@@ -625,8 +670,12 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
625
670
  }
626
671
  if (result.error)
627
672
  lines.push(theme.fg("error", `${result.error.code}: ${compact(result.error.message, 120)}`));
673
+ if ("pause" in result && result.pause)
674
+ lines.push(theme.fg("warning", `job paused by ${compact(result.pause.source, 20)} · no new executions start`));
628
675
  if ("pausedExecutionIds" in result && result.pausedExecutionIds?.length)
629
676
  lines.push(theme.fg("warning", `${result.pausedExecutionIds.length} paused executions · revision ${result.revision ?? 0}`));
677
+ if ("suspendedExecutionIds" in result && result.suspendedExecutionIds?.length)
678
+ lines.push(theme.fg("warning", `${result.suspendedExecutionIds.length} suspended executions · revision ${result.revision ?? 0}`));
630
679
  if ("executions" in result)
631
680
  lines.push(theme.fg("dim", `${Object.keys(result.executions).length} executions · revision ${result.revision}`));
632
681
  const chartStart = lines.length;